diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..5e7ace5 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,38 @@ +# HANDOFF — feat/chat-redesign (Task 1: chat) + +_Last updated: Jul 28 2026, end of session. Tip `1447f62`._ + +## Status +- **Branch `feat/chat-redesign` (PR #39) — OPEN, HELD. Do NOT merge to `main` without the founder's explicit go.** GitHub reports CLEAN / MERGEABLE (main not diverged), 64 commits today. +- PR **#37** (chat-first shell) and **#40** (Firebase test-host flake fix) already **merged to main**. +- **Chat UI/UX workstream = 16/16 Done** on the W1 board (page `3aa3af4aaa9281b1bc09d051a59b0ca6`). "Done" = work-complete + founder-verified; still branch-held (Done-on-board ≠ merged). +- **AI is DOWN** — no Anthropic credits/key. Use the **mock** for any front-end display (below). + +## What's built on this branch +Redesign (un-bubbled messages + thumbs, one card grammar, luminous orb, dept chips, live empty-state) · **streaming execute-log** · **dept→companion handoff** (specialists appear as pet sprites) · **thread header** (dropdown switcher + Share=copy transcript) · **dark mode** (Light/Dark toggle works via `NSApp.appearance`; `onAccent` text) · **responsive** column (760 + 24pt gutter, shrink-to-fit) · **chat persistence** (Firestore `companies/{uid}/threads/{id}`, survives restart) · **DEBUG mock harness**. + +## Run it (mock, no key needed) +``` +# build TEAM-SIGNED, signed build LAST (tests clobber signing), then verify: +xcodebuild build -project CodePet.xcodeproj -scheme codepet -destination 'platform=macOS' \ + -allowProvisioningUpdates CODE_SIGN_STYLE=Automatic DEVELOPMENT_TEAM=YL72VTKBR7 "CODE_SIGN_IDENTITY=Apple Development" +codesign -dv 2>&1 | grep TeamIdentifier # MUST be YL72VTKBR7 (not adhoc — else Firebase keychain breaks) +# launch with the offline mock ON: +open -n --args -CODEPET_MOCK_CHAT YES +``` +Mock is keyword-routed: any question → advisor reply; `run my first task` → execute-log → tailored draft (positioning / landing copy / waitlist / pricing / interview); `remember…` → Noted chip; `connect…` → enable card; `roadmap`/dept chip → nav/handoff. Seed the board via **Roadmap** first if "no task to run". + +## Gotchas +- `xcodebuild test … CODE_SIGNING_ALLOWED=NO` rebuilds the app **ad-hoc** into the same DerivedData → breaks Firebase keychain on launch. Always: tests first, **signed build last**, verify TeamIdentifier, then launch. +- Canonical work checkout drifted to `~/Developer/codepet` (iCloud evicted ~/Documents & ~/Desktop repos; `~/codepet-chat` is behind origin). Prefer a fresh non-iCloud checkout of `feat/chat-redesign`. +- **Two parallel sessions** run — own branch, own worktree, serialized app testing (one launches at a time). See memory `codepet-parallel-session-protocol`. + +## Next candidates (not started) +Message-cap per thread · search Recent · clear-all-history. Add a W1 board row before building (the board is the source of truth). + +## Resume-last-thread on launch (built, unverified in-app) +Launch now reopens the newest thread when it was touched inside `threadResumeWindow` (**8h**), else opens the live hero — founder's call, so a new day still starts on the time-of-day greeting + roadmap cards. Pure `pickResumeThreadId(in:now:within:)` in `ChatThreads.swift`, called from `CompanyStore.hydrate`. Two adjacent fixes came with it: the transcript now pins to its newest message when it arrives already-populated (resume, or switching into an existing thread — it used to open at the OLDEST message, since it only scrolled on a message-COUNT change), and the enrichment interview survives a relaunch (`interviewState` is session-only, so answering a resumed question used to save the answer and then go silent; `EnrichInterview.remainingGaps` rebuilds the cursor from the brief minus what the transcript already answered, so a skip stays skipped). 10 new tests. **Not yet exercised in the running app** — needs a signed build + a real quit/relaunch inside the window. + +DEBUG-only `-CODEPET_RESUME_WINDOW_SECONDS ` (same idiom as `-CODEPET_MOCK_CHAT`, only a POSITIVE value counts) shrinks the window so the stale branch is testable in seconds instead of eight hours. Driver script: `~/Developer/codepet-test-resume.sh` — `launch` / `relaunch` (the quit→resume loop, graceful quit so pending thread writes aren't cut off) / `stale` / `lock`. + +**Test-suite gotcha found this session:** any running `codepet.app` holds `~/Library/Application Support/firestore/__FIRAPP_DEFAULT/devpet-8f4b1/main/LOCK`, and a second process that initializes Firestore aborts in `FirestoreClient::Initialize` (SIGABRT). That is the "Firebase test-host flake" — every test class that builds a `CompanyStore` with the DEFAULT `threadsLoader` dies, 0 tests executed, while classes injecting fakes pass. Close the app before `xcodebuild test`. diff --git a/codepet/App/CodePetApp.swift b/codepet/App/CodePetApp.swift index cbdb44a..8660d35 100644 --- a/codepet/App/CodePetApp.swift +++ b/codepet/App/CodePetApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit import FirebaseCore import GoogleSignIn @@ -42,9 +43,28 @@ struct CodePetApp: App { _chatController = StateObject(wrappedValue: chatController) } + /// Force the app's effective appearance so the CodepetTheme dyn NSColor tokens + /// re-resolve on a Light/Dark pick. `.system` → nil (follow macOS). + private static func applyAppearance(_ theme: AppTheme) { + switch theme { + case .system: NSApplication.shared.appearance = nil + case .light: NSApplication.shared.appearance = NSAppearance(named: .aqua) + case .dark: NSApplication.shared.appearance = NSAppearance(named: .darkAqua) + } + } + var body: some Scene { WindowGroup { - ContentView() + Group { +#if DEBUG + // Dev-only: `-CODEPET_MOCK_GALLERY YES` swaps the root for the + // chat-mocks gallery so the scenario mockups are viewable in a + // running build (see ChatMocksGalleryView). + if MockGallery.enabled { ChatMocksGalleryView() } else { ContentView() } +#else + ContentView() +#endif + } .environment(\.font, CodepetTheme.body(13)) .environment(\.uiLanguage, appState.uiLanguage) .environmentObject(appState) @@ -73,9 +93,20 @@ struct CodePetApp: App { .environmentObject(learnProgress) .environmentObject(challengeProgress) .environmentObject(feedbackManager) - .frame(minWidth: 400, minHeight: 700) + // 560 = the onboarding card's real floor: a 320pt clamped art panel + // (OnboardingLayout.artWidth) + the form's 64pt horizontal padding either + // side + room for the 600pt measure to compress into. At 400 the form + // panel was driven to a negative width and the layout clipped. + .frame(minWidth: 560, minHeight: 700) .themed(isDark: appState.appTheme == .dark) .preferredColorScheme(appState.appTheme.colorScheme) + // preferredColorScheme drives SwiftUI-native colors, but the + // CodepetTheme.dyn tokens resolve via dynamic NSColor keyed on the + // app's EFFECTIVE appearance — which preferredColorScheme does NOT + // force. So a Light/Dark pick would leave the tokens on the system + // appearance. Force NSApp.appearance to make every dyn token flip. + .onChange(of: appState.appTheme) { _, theme in Self.applyAppearance(theme) } + .onAppear { Self.applyAppearance(appState.appTheme) } .task { projectStore.load() TipsPersistence.shared.load(into: tipsState) diff --git a/codepet/Managers/ChatThreads.swift b/codepet/Managers/ChatThreads.swift index cf8eaca..91a1329 100644 --- a/codepet/Managers/ChatThreads.swift +++ b/codepet/Managers/ChatThreads.swift @@ -1,12 +1,11 @@ // codepet/Managers/ChatThreads.swift import Foundation -/// One session-only chat conversation — a named bucket of `CopilotMessage`s. -/// Deliberately NOT Codable: Level 1 multi-thread history is in-memory only, -/// same as `chatMessages` itself (see `CopilotMessage`'s doc comment). Native -/// port of the web `ThreadMeta` (`lib/firebase/schema.ts`) + its message array, -/// merged into one struct since there's no persistence layer to split them. -struct ChatThread: Identifiable, Equatable { +/// One chat conversation — a named bucket of `CopilotMessage`s. Codable so a +/// thread can be persisted to `companies/{uid}/threads/{id}` and hydrated on +/// launch. Native port of the web `ThreadMeta` (`lib/firebase/schema.ts`) + its +/// message array, merged into one struct. +struct ChatThread: Identifiable, Equatable, Codable { let id: String /// nil until the founder's first message derives one (or a rename sets a /// non-blank title) — rendered as "New chat" by the view, mirrors the web. @@ -14,6 +13,17 @@ struct ChatThread: Identifiable, Equatable { var messages: [CopilotMessage] let createdAt: Date var updatedAt: Date + + /// A copy safe to PERSIST: drops transient "producing" placeholders (they + /// exist only mid-run) and clears streaming-only `execSteps`, so a saved + /// thread never resurrects a half-finished run on the next launch. + var persistable: ChatThread { + var copy = self + copy.messages = messages + .filter { !$0.producing } + .map { m in var mm = m; mm.execSteps = nil; return mm } + return copy + } } // Pure, unit-testable helpers behind the thread switcher — ported from the @@ -30,7 +40,12 @@ private let threadTitleMax = 40 /// ellipsis. Returns nil when there is no `.me` message yet, OR its text is /// blank — the view falls back to "New chat" for either case, same as the web. func deriveThreadTitle(_ messages: [CopilotMessage]) -> String? { - guard let first = messages.first(where: { $0.role == .me }) else { return nil } + // Prefer the founder's first message; fall back to the first non-empty + // message of ANY role so a thread that so far only holds byte's seeded + // question/greeting still gets a distinguishable name instead of "New chat". + let source = messages.first(where: { $0.role == .me && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) + ?? messages.first(where: { !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) + guard let first = source else { return nil } let collapsed = first.text.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) guard !collapsed.isEmpty else { return nil } @@ -52,6 +67,28 @@ func pickFallbackThreadId(after deletedId: String, in threads: [ChatThread]) -> sortThreadsByRecent(threads.filter { $0.id != deletedId }).first?.id } +/// How long after its last message a thread still counts as the founder's +/// current working stretch. Inside it, launch resumes that thread; outside it, +/// launch opens the live hero instead — so quitting and reopening keeps your +/// place, while a new day still starts on the time-of-day greeting and the +/// roadmap landing cards. One constant to tune. +let threadResumeWindow: TimeInterval = 8 * 3600 + +/// Which thread launch should reopen, or nil to land on the empty hero. The +/// newest thread that actually holds messages, provided it was touched within +/// `window`. `now` is a parameter (not `Date()` inside) for testability, same as +/// `relativeTime`. A future-dated `updatedAt` (clock skew from another device) +/// counts as recent rather than stale. +/// +/// Order-agnostic: callers may pass threads in any order (a Firestore query's +/// ordering isn't guaranteed to survive a decode-and-filter pass), so this sorts +/// before picking rather than trusting the input. +func pickResumeThreadId(in threads: [ChatThread], now: Date, within window: TimeInterval = threadResumeWindow) -> String? { + guard let newest = sortThreadsByRecent(threads).first(where: { !$0.messages.isEmpty }) else { return nil } + guard now.timeIntervalSince(newest.updatedAt) <= window else { return nil } + return newest.id +} + /// Compact relative time for the history list — "just now" under a minute, /// then minutes/hours/days. English only (the pure helper stays language- /// neutral like the rest of `ChatThreads`; the view localizes surrounding diff --git a/codepet/Managers/CompanyStore.swift b/codepet/Managers/CompanyStore.swift index 162251e..fe202a3 100644 --- a/codepet/Managers/CompanyStore.swift +++ b/codepet/Managers/CompanyStore.swift @@ -1,6 +1,8 @@ // codepet/Managers/CompanyStore.swift import Foundation import Combine +import FirebaseFirestore +import FirebaseAuth /// The app's primary store — the single company (companies/{uid}) + the active /// view. Native port of the web `useApp`/`lib/store`. Replaces ProjectStore's @@ -39,6 +41,15 @@ final class CompanyStore: ObservableObject { /// and `sendChat`'s unconditional tail) — never stuck true. @Published private(set) var isStreaming = false @Published private(set) var runningTaskIds: Set = [] + /// Live parallel department-agent runs (the chat fan-out). Rendered as one + /// AgentsWorkingRow; empty ⇒ no row. Seeded by `fanOutNextMoves`, cleared when + /// the whole fan-out completes; each agent's draft lands in `chatMessages`. + @Published var activeAgentRuns: [AgentRun] = [] + /// True while a fan-out is in flight — serializes it against a normal chat turn + /// and disables the composer (same busy model as a single run). + @Published private(set) var isFanningOut: Bool = false + /// Max concurrent department agents per fan-out (bounds credit spend + latency). + static let maxFanOut = 3 @Published private(set) var runError: String? @Published private(set) var isGeneratingRoadmap = false /// The Company view's open department, if any — PROMOTED from AppShellView's @@ -67,6 +78,11 @@ final class CompanyStore: ObservableObject { private let enricher: (CompanyBrief) async throws -> CompanyBrief private let decisionsSaver: (String, [DecisionEntry]) async -> Bool private let decisionExtractor: (ApprovedDeliverableDTO, [DecisionEntry]) async -> [ExtractedDecision] + // Chat persistence seam (companies/{uid}/threads). Injectable + fail-soft so + // tests never touch Firestore. + private let threadSaver: (String, ChatThread) async -> Bool + private let threadDeleter: (String, String) async -> Bool + private let threadsLoader: (String) async -> [ChatThread] /// Bumped on every hydrate/reset; lets a suspended hydrate detect it has /// been superseded (account switch mid-flight) and discard its result @@ -97,7 +113,10 @@ final class CompanyStore: ObservableObject { companionSaver: @escaping (String, String) async -> Bool = CompanyData.saveCompanionId, enricher: @escaping (CompanyBrief) async throws -> CompanyBrief = { try await ReflectionAPIClient().enrichBrief($0) }, decisionsSaver: @escaping (String, [DecisionEntry]) async -> Bool = CompanyData.saveDecisions, - decisionExtractor: @escaping (ApprovedDeliverableDTO, [DecisionEntry]) async -> [ExtractedDecision] = DecisionsClient.extract) { + decisionExtractor: @escaping (ApprovedDeliverableDTO, [DecisionEntry]) async -> [ExtractedDecision] = DecisionsClient.extract, + threadSaver: @escaping (String, ChatThread) async -> Bool = ChatPersistence.saveThread, + threadDeleter: @escaping (String, String) async -> Bool = ChatPersistence.deleteThread, + threadsLoader: @escaping (String) async -> [ChatThread] = ChatPersistence.loadThreads) { self.loader = loader self.saver = saver self.roadmapFetcher = roadmapFetcher @@ -111,6 +130,9 @@ final class CompanyStore: ObservableObject { self.enricher = enricher self.decisionsSaver = decisionsSaver self.decisionExtractor = decisionExtractor + self.threadSaver = threadSaver + self.threadDeleter = threadDeleter + self.threadsLoader = threadsLoader } func select(_ view: AppView) { self.view = view } @@ -121,6 +143,26 @@ final class CompanyStore: ObservableObject { company.onboardedAt == nil && !company.brief.hasAnySignal } + /// The resume window this launch actually uses. Release builds always use + /// `threadResumeWindow` (8h). A DEBUG build honours an override so the STALE + /// branch — which otherwise needs a thread untouched for eight hours — can be + /// exercised in seconds, same idiom as `CODEPET_MOCK_CHAT`: + /// + /// open -n --args -CODEPET_RESUME_WINDOW_SECONDS 10 + /// defaults write app.murror.codepet CODEPET_RESUME_WINDOW_SECONDS 10 # persistent + /// defaults delete app.murror.codepet CODEPET_RESUME_WINDOW_SECONDS # back to 8h + /// + /// Only a POSITIVE override counts: an absent or unparseable key reads as 0 + /// through `double(forKey:)`, and 0 would mean "resume nothing, ever" — a + /// silent behaviour change on any build that happened to carry a stray key. + static var resumeWindow: TimeInterval { + #if DEBUG + let override = UserDefaults.standard.double(forKey: "CODEPET_RESUME_WINDOW_SECONDS") + if override > 0 { return override } + #endif + return threadResumeWindow + } + /// Hydrate the company from Firestore (fail-soft inside the loader). func hydrate(companyId: String) async { hydrationToken &+= 1 @@ -128,13 +170,16 @@ final class CompanyStore: ObservableObject { // Chat is per-account + session-only. An actual account change clears it // (and any stuck typing); a same-user re-hydrate (token refresh/reconnect) // preserves the in-flight conversation. - if self.companyId != companyId { + let accountChanged = self.companyId != companyId + if accountChanged { chatMessages = [] threads = [] activeThreadId = nil isCompanionTyping = false isStreaming = false runningTaskIds = [] + activeAgentRuns = [] + isFanningOut = false runError = nil } self.companyId = companyId @@ -142,6 +187,22 @@ final class CompanyStore: ObservableObject { let loaded = await loader(companyId) guard token == hydrationToken else { return } // a newer hydrate/reset superseded us company = loaded + // Hydrate the persisted Recent list on an account change (a same-user + // re-hydrate keeps the live in-memory threads, which may be newer than + // Firestore). Then resume where the founder left off IF they were here + // recently (`pickResumeThreadId`) — otherwise activeThreadId stays nil + // and the buffer stays empty, opening the live hero. Resuming is safe: + // persisted threads are `.persistable` projections, so no half-finished + // run (producing placeholders, execSteps) comes back with them. + if accountChanged { + let persisted = await threadsLoader(companyId) + guard token == hydrationToken else { return } + threads = persisted + if let resumeId = pickResumeThreadId(in: persisted, now: Date(), within: Self.resumeWindow) { + activeThreadId = resumeId + chatMessages = persisted.first(where: { $0.id == resumeId })?.messages ?? [] + } + } isHydrating = false isOnboarding = needsOnboarding onboardingToken = hydrationToken @@ -220,7 +281,15 @@ final class CompanyStore: ObservableObject { guard companyId == cid else { return } // account switched mid-await → bail } - guard var st = interviewState else { return } + // No cursor means the founder quit mid-interview and relaunched into a + // resumed thread: `interviewState` is session-only, while the questions + // themselves persisted with the thread. Rebuild the cursor from the brief + // and carry the chain on, rather than saving this one answer and going + // silent (no next question, no greeting). + guard var st = interviewState else { + resumeInterviewAfterRelaunch(language: language) + return + } st.idx += 1 if st.idx < st.gaps.count { interviewState = st @@ -231,6 +300,33 @@ final class CompanyStore: ObservableObject { } } + /// Pick the interview back up after a relaunch: the remaining gaps are the + /// brief's still-empty fields MINUS every gap the transcript already shows + /// answered (so a skipped question isn't re-asked forever — a skip leaves its + /// field empty on purpose). Nothing left → hand off to the first-run greeting, + /// exactly as a normally-completed interview does. + private func resumeInterviewAfterRelaunch(language: AppLanguage) { + let answered = chatMessages.compactMap { $0.interviewAnswered ? $0.interview : nil } + let remaining = EnrichInterview.remainingGaps(company.brief, answered: answered) + if let next = remaining.first { + interviewState = (gaps: remaining, idx: 0) + askInterviewGap(next, language: language) + } else { + interviewState = nil + seedFirstRunGreeting(language: language) + } + } + + /// The enrichment question currently awaiting an answer, if any — the last + /// companion message carrying an unanswered `interview` gap. Lets the main + /// composer answer the question conversationally (no embedded form): the view + /// routes a send here instead of `sendChat` while this is non-nil. + var pendingInterview: (id: String, gap: InterviewGap)? { + guard let m = chatMessages.last(where: { $0.interview != nil && !$0.interviewAnswered }), + let gap = m.interview else { return nil } + return (m.id, gap) + } + /// Skip: stamp with the current (empty) brief so they aren't re-blocked. Called /// directly from the view (no prior await); capture the token at entry and re-check /// after the save await. @@ -287,13 +383,29 @@ final class CompanyStore: ObservableObject { if let cid = companyId { _ = await tasksSaver(cid, company.tasks) } } + /// Record a per-message thumb up/down to the `feedback` collection. Fire-and- + /// forget, create-only, guarded like FeatureFeedbackManager — never writes + /// under XCTest or when server logging is opted out. + func reactToMessage(messageId: String, helpful: Bool) { + guard !AppEnvironment.isRunningTests, !ServerLoggingGate.isOptedOut else { return } + var data = MessageFeedback( + messageId: messageId, helpful: helpful, + companyId: companyId ?? "unknown", userId: Auth.auth().currentUser?.uid ?? "anonymous", + companionId: company.companionId + ).firestoreData() + data["timestamp"] = FieldValue.serverTimestamp() + Firestore.firestore().collection("feedback").addDocument(data: data) { error in + if let error { print("[Feedback] chat reaction error: \(error.localizedDescription)") } + } + } + /// Send a founder-typed message to the company companion. Trims + validates, /// then hands off to `sendMessage` (the shared streamed-send core — see its /// doc comment for the full flow, fallback, and token-guard semantics). - func sendChat(_ raw: String, language: AppLanguage) async { + func sendChat(_ raw: String, language: AppLanguage, department: Department? = nil) async { let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } - await sendMessage(text, language: language) + await sendMessage(text, language: language, department: department) } // MARK: - Chat threads (session-only, Level 1 — no persistence, no summarization) @@ -316,6 +428,12 @@ final class CompanyStore: ObservableObject { threads.append(ChatThread(id: id, title: deriveThreadTitle(chatMessages), messages: chatMessages, createdAt: now, updatedAt: now)) } + // Persist the flushed thread (fail-soft, fire-and-forget). The captured + // companyId keeps the write scoped to THIS account even if it switches + // mid-flight, and `.persistable` (inside the saver) strips transient run state. + if let cid = companyId, let thread = threads.first(where: { $0.id == id }) { + Task { _ = await threadSaver(cid, thread) } + } } /// Start a fresh, empty conversation: flush the outgoing thread (if it ever @@ -361,6 +479,7 @@ final class CompanyStore: ObservableObject { guard let i = threads.firstIndex(where: { $0.id == id }) else { return } let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) threads[i].title = trimmed.isEmpty ? nil : trimmed + if let cid = companyId { let thread = threads[i]; Task { _ = await threadSaver(cid, thread) } } } /// Delete a thread. Deleting a non-active thread just removes it. Deleting @@ -376,6 +495,7 @@ final class CompanyStore: ObservableObject { func deleteThread(_ id: String) { guard !isStreaming, !isCompanionTyping else { return } threads.removeAll { $0.id == id } + if let cid = companyId { Task { _ = await threadDeleter(cid, id) } } guard id == activeThreadId else { return } if let fallback = pickFallbackThreadId(after: id, in: threads) { activeThreadId = fallback @@ -440,7 +560,19 @@ final class CompanyStore: ObservableObject { /// fall back to the existing non-streaming `chatSender(req)` and fill the SAME /// placeholder — preserving every existing semantic: the offline copy, the /// runTaskId/draft-run chaining, and the account guard. - private func sendMessage(_ text: String, language: AppLanguage) async { + /// The specialist companion to bring in for this turn, if a department is in + /// focus — from the explicit chip, else a department named in the text. Returns + /// nil when no department applies, it has no mapped companion, or it maps to the + /// current host companion (no visible handoff needed). + private func actingSpecialist(text: String, department: Department?) -> (companionId: String, deptName: String)? { + let deptKey = department?.key ?? DepartmentCompanions.mentionedDeptKey(in: text) + guard let deptKey, let dept = DepartmentCatalog.find(deptKey), + let companionId = DepartmentCompanions.companionId(for: deptKey), + companionId != company.companionId else { return nil } + return (companionId, dept.name) + } + + private func sendMessage(_ text: String, language: AppLanguage, department: Department? = nil) async { guard !isCompanionTyping, !isStreaming else { return } chatMessages.append(CopilotMessage(role: .me, text: text)) isCompanionTyping = true @@ -463,11 +595,24 @@ final class CompanyStore: ObservableObject { let req = CompanyChatRequest( companyId: companyId, language: language.rawValue, companionId: company.companionId, context: ChatContext.compose(brief: company.brief, tasks: company.tasks, decisions: company.decisions, - library: company.library, query: text), + library: company.library, query: text, focusDepartment: department), history: Array(history), userMessage: text, runnable: Array(runnable), envSetup: envSetup) + // Department handoff: if a specialist leads this turn (chip focus or a + // department named in the text), the host posts a one-line handoff and the + // reply is spoken by that specialist (orb re-tint + "Name · Dept" label). + let specialist = actingSpecialist(text: text, department: department) + if let s = specialist { + let name = PetCharacter.all[s.companionId]?.name ?? (language == .vi ? "chuyên gia" : "your specialist") + let handoff = language == .vi + ? "Mời \(name) — phụ trách \(s.deptName) — vào cùng nhé." + : "Bringing in \(name), your \(s.deptName) lead —" + chatMessages.append(CopilotMessage(role: .companion, text: handoff)) + } + let placeholderId = UUID().uuidString - chatMessages.append(CopilotMessage(id: placeholderId, role: .companion, text: "")) + chatMessages.append(CopilotMessage(id: placeholderId, role: .companion, text: "", + companionId: specialist?.companionId, deptName: specialist?.deptName)) isStreaming = true var streamedText = "" @@ -551,26 +696,191 @@ final class CompanyStore: ObservableObject { /// `cid` is the `companyId` captured at the start of `sendChat` — re-checked /// after the `taskRunner` await so an account switch mid-run can't append /// this account's draft into a different (already-hydrated) account's chat. + /// Execute-log pacing (tunable; tests set to 0 to stay instant). `stepNanos` + /// is the delay between revealing each step; `doneBeatNanos` is the hold after + /// the result lands so the completed log reads before collapsing to the draft. + static var execStepNanos: UInt64 = 420_000_000 + static var execDoneBeatNanos: UInt64 = 260_000_000 + + /// The execute-log steps for a run — a truthful description of the pipeline the + /// deliverable goes through (the request genuinely carries the brief, decisions, + /// and department context). Revealed progressively as the run proceeds. + static func execSteps(task: RoadmapTask, specialist: (companionId: String, deptName: String)?, + decisionCount: Int, language: AppLanguage) -> [ExecStep] { + let vi = language == .vi + var out: [ExecStep] = [] + let ctx = decisionCount > 0 + ? (vi ? "Đọc brief — sứ mệnh, khách hàng, giọng điệu (và \(decisionCount) quyết định)" + : "Reading your brief — mission, audience, your voice (+ \(decisionCount) decisions)") + : (vi ? "Đọc brief — sứ mệnh, khách hàng, giọng điệu của bạn" + : "Reading your brief — mission, audience, your voice") + out.append(ExecStep(label: ctx)) + if let s = specialist { + out.append(ExecStep(label: vi ? "Vận dụng cẩm nang \(s.deptName)" : "Pulling in the \(s.deptName) playbook")) + } + out.append(ExecStep(label: vi ? "Soạn \(task.title)" : "Drafting \(task.title)")) + out.append(ExecStep(label: vi ? "Khớp giọng điệu và quyết định của bạn" : "Matching your tone and past decisions")) + return out + } + + /// The specialist for a task's owning department, if it maps to a companion + /// other than the host — used to attribute the run's producing row + draft. + private func taskSpecialist(for task: RoadmapTask) -> (companionId: String, deptName: String)? { + guard let deptKey = task.dept, let dept = DepartmentCatalog.find(deptKey), + let companionId = DepartmentCompanions.companionId(for: deptKey), + companionId != company.companionId else { return nil } + return (companionId, dept.name) + } + private func handleRunTaskId(_ runId: String?, cid: String?, language: AppLanguage) async { guard let runId, let task = company.tasks.first(where: { $0.id == runId }), RoadmapEngine.status(for: task, in: company.tasks) == .codepetCanDo else { return } - // Transparency step: a transient "producing…" placeholder shows while the - // draft is generated (this run isn't tracked in `runningTaskIds` — that set - // only covers taps on the map/beacon card — so a chat message is the - // simplest robust signal). Always removed below before the real reply - // lands, on BOTH the success and failure branch, so it can never get stuck. + await produceDraftInline(for: task, cid: cid, language: language) + } + + /// The single inline-run path shared by EVERY chat run (typed "run" command + /// AND the greeting's "Do it with me"), so "how the agent works" shows the same + /// everywhere: a transient producing placeholder drives the execute-log — a + /// step checklist revealed progressively (transparency, not a snap-to-done) — + /// attributed to the task's department specialist (pet sprite + "Name · Dept"). + /// The last step stays "working" until BOTH the reveal and the real result + /// finish, then it collapses into the draft card. Returns true if a draft was + /// appended (false → an honest "couldn't generate" bubble). Account-guarded + /// via `cid` so a mid-run account switch can't land in another account's chat. + @discardableResult + private func produceDraftInline(for task: RoadmapTask, cid: String?, language: AppLanguage) async -> Bool { + let specialist = taskSpecialist(for: task) + let steps = Self.execSteps(task: task, specialist: specialist, + decisionCount: company.decisions.count, language: language) let producingId = UUID().uuidString - chatMessages.append(CopilotMessage(id: producingId, role: .companion, text: "", producing: true)) + chatMessages.append(CopilotMessage(id: producingId, role: .companion, text: task.title, producing: true, + companionId: specialist?.companionId, deptName: specialist?.deptName, + execSteps: steps)) + let reveal = Task { [cid] in + for idx in 0.. String { switch self { diff --git a/codepet/Models/Character.swift b/codepet/Models/Character.swift index a642b6e..77a76c0 100644 --- a/codepet/Models/Character.swift +++ b/codepet/Models/Character.swift @@ -6,6 +6,7 @@ struct PetCharacter: Identifiable { let badge: String let color: Color let hexColor: String + let secondHexColor: String let personality: String let domain: String let greeting: [String] @@ -28,13 +29,15 @@ struct PetCharacter: Identifiable { /// Asset catalog image name: "char-byte", "char-nova", etc. var imageName: String { "char-\(id)" } + var secondColor: Color { Color(hex: secondHexColor) } + static let starters = ["byte", "nova", "crash", "luna", "sage", "glitch", "null"] // swiftlint:disable function_body_length static let all: [String: PetCharacter] = [ "byte": PetCharacter( id: "byte", name: "Codepet", badge: "The Chaotic Core", - color: Color(hex: "#8B7BE8"), hexColor: "#8B7BE8", + color: Color(hex: "#8B7BE8"), hexColor: "#8B7BE8", secondHexColor: "#4EC9D4", personality: "glitchy, chaotic, thinks in fragments", domain: "Data / ML", greeting: ["*static crackle* ...hey.", "Fragments loading... oh, it's you."], @@ -48,7 +51,7 @@ struct PetCharacter: Identifiable { ), "nova": PetCharacter( id: "nova", name: "Nova", badge: "The Firestarter", - color: Color(hex: "#FF8C00"), hexColor: "#FF8C00", + color: Color(hex: "#FF8C00"), hexColor: "#FF8C00", secondHexColor: "#6EA8FF", personality: "energetic, bold, moves fast", domain: "Frontend Dev", greeting: ["Let's BUILD! 🔥", "Speed run? Speed run."], @@ -62,7 +65,7 @@ struct PetCharacter: Identifiable { ), "crash": PetCharacter( id: "crash", name: "Crash", badge: "The Brawler Bug", - color: Color(hex: "#E04040"), hexColor: "#E04040", + color: Color(hex: "#E04040"), hexColor: "#E04040", secondHexColor: "#F0A860", personality: "tough love, breaks things to learn", domain: "Backend Dev", greeting: ["SMASH first, ask questions later!", "You again? Good. Let's break stuff."], @@ -76,7 +79,7 @@ struct PetCharacter: Identifiable { ), "luna": PetCharacter( id: "luna", name: "Luna", badge: "The Creative Builder", - color: Color(hex: "#5B8DEF"), hexColor: "#5B8DEF", + color: Color(hex: "#5B8DEF"), hexColor: "#5B8DEF", secondHexColor: "#C99BF0", personality: "warm, encouraging, creative", domain: "Designer (UX/UI)", greeting: ["Hey you~ ready to create something?", "I had an idea while you were gone..."], @@ -90,7 +93,7 @@ struct PetCharacter: Identifiable { ), "sage": PetCharacter( id: "sage", name: "Sage", badge: "The Zen Debugger", - color: Color(hex: "#20B090"), hexColor: "#20B090", + color: Color(hex: "#20B090"), hexColor: "#20B090", secondHexColor: "#FDC352", personality: "calm, wise, methodical", domain: "Product Owner", greeting: ["Breathe. Then build.", "The bug is not in the code. It's in the approach."], @@ -104,7 +107,7 @@ struct PetCharacter: Identifiable { ), "glitch": PetCharacter( id: "glitch", name: "Glitch", badge: "The Punk Hacker", - color: Color(hex: "#E0508C"), hexColor: "#E0508C", + color: Color(hex: "#E0508C"), hexColor: "#E0508C", secondHexColor: "#5AD0E0", personality: "rebellious, clever, unconventional", domain: "DevOps", greeting: ["Rules? Where we're going, we don't need rules.", "Hack the planet! ...or at least this component."], @@ -118,7 +121,7 @@ struct PetCharacter: Identifiable { ), "null": PetCharacter( id: "null", name: "Null", badge: "The Chaos Gremlin", - color: Color(hex: "#80C830"), hexColor: "#80C830", + color: Color(hex: "#80C830"), hexColor: "#80C830", secondHexColor: "#5AD0E0", personality: "chaotic, silly, unpredictable", domain: "Mobile Dev", greeting: ["¿¡HOLA!? Did someone say chaos?", "I deleted something important! Just kidding. ...or am I?"], diff --git a/codepet/Models/ChatContext.swift b/codepet/Models/ChatContext.swift index 2a1b2f6..ef7bb0c 100644 --- a/codepet/Models/ChatContext.swift +++ b/codepet/Models/ChatContext.swift @@ -99,9 +99,14 @@ enum ChatContext { } static func compose(brief: CompanyBrief, tasks: [RoadmapTask], decisions: [DecisionEntry] = [], - library: [Deliverable] = [], query: String? = nil) -> String { + library: [Deliverable] = [], query: String? = nil, + focusDepartment: Department? = nil) -> String { var parts: [String] = [] parts.append(BriefContext.compose(brief) ?? "No brief yet.") + if let dep = focusDepartment { + parts.append("The founder is focused on the \(dep.name) department right now — " + + "prioritize \(dep.name) in your answer: \(dep.focus)") + } let d = Decisions.composeDecisions(decisions) if !d.isEmpty { parts.append(d) } parts.append("Roadmap progress: \(RoadmapEngine.progressPercent(tasks))%.") diff --git a/codepet/Models/ChatLandingState.swift b/codepet/Models/ChatLandingState.swift new file mode 100644 index 0000000..a192484 --- /dev/null +++ b/codepet/Models/ChatLandingState.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Pure landing-state for the empty chat: greeting + the live roadmap signals +/// that drive the landing cards. Deterministic given `now`. SwiftUI-free. +struct ChatLandingState { + let greeting: String + let question: String + let beacon: RoadmapTask? + let needsYouCount: Int + let awaitingApprovalCount: Int + let isEmpty: Bool + + init(company: CompanyState, now: Date, language: AppLanguage) { + let founderRaw = (company.brief.founderName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let founder = founderRaw.isEmpty ? (language == .vi ? "bạn" : "there") : founderRaw + let hour = Calendar.current.component(.hour, from: now) + let part: String + switch hour { + case ..<12: part = language == .vi ? "Chào buổi sáng" : "Good morning" + case 12..<18: part = language == .vi ? "Chào buổi chiều" : "Good afternoon" + default: part = language == .vi ? "Chào buổi tối" : "Good evening" + } + greeting = "\(part), \(founder)." + + let projectRaw = (company.brief.projectName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let project = projectRaw.isEmpty ? "Codepet" : projectRaw + question = language == .vi ? "Hôm nay mình xây gì cho \(project)?" : "What should we build for \(project) today?" + + let tasks = company.tasks + let next = RoadmapEngine.nextStep(tasks) + beacon = next + needsYouCount = tasks.filter { RoadmapEngine.status(for: $0, in: tasks) == .needsYou && $0.id != next?.id }.count + awaitingApprovalCount = tasks.filter { RoadmapEngine.status(for: $0, in: tasks) == .needsApproval }.count + isEmpty = tasks.isEmpty + } +} diff --git a/codepet/Models/ChatMode.swift b/codepet/Models/ChatMode.swift new file mode 100644 index 0000000..706a287 --- /dev/null +++ b/codepet/Models/ChatMode.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Client-side chat "mode". Shapes the founder's raw message with an intent, +/// then hands the plain string to the existing `CompanyStore.sendChat`. There +/// is no backend concept of modes and no build session — this is pure +/// message-shaping, which is why it is a small, unit-testable value type. +enum ChatMode: CaseIterable, Identifiable { + case ask, plan, build + + var id: Self { self } + + /// Short control label — matches the terse pill style used elsewhere in chat. + func label(_ lang: AppLanguage) -> String { + switch (self, lang) { + case (.ask, .vi): return "Hỏi" + case (.ask, _): return "Ask" + case (.plan, .vi): return "Lập kế hoạch" + case (.plan, _): return "Plan" + case (.build, .vi): return "Bắt tay làm" + case (.build, _): return "Build" + } + } + + /// Wrap the founder's raw text with this mode's intent. `.ask` is identity. + /// `.build` copy is deliberately modest — the chat can already run tasks and + /// produce draft deliverables; it must NOT imply the (not-yet-native) build agent. + func shape(_ text: String, language lang: AppLanguage) -> String { + switch self { + case .ask: + return text + case .plan: + return lang == .vi + ? "Giúp mình lập kế hoạch — nêu các bước cụ thể tiếp theo: \(text)" + : "Help me plan this — give me the concrete next steps: \(text)" + case .build: + return lang == .vi + ? "Cùng bắt tay làm luôn — nếu là việc bạn làm được, hãy chạy và cho mình xem bản nháp: \(text)" + : "Let's build this together — if it's a task you can do, run it and show me a draft: \(text)" + } + } +} diff --git a/codepet/Models/ChatThinkingLabel.swift b/codepet/Models/ChatThinkingLabel.swift new file mode 100644 index 0000000..5f56276 --- /dev/null +++ b/codepet/Models/ChatThinkingLabel.swift @@ -0,0 +1,14 @@ +import Foundation + +/// The streaming-state label copy. Pure + localized. Names the in-flight work +/// when a real title exists; otherwise a generic, honest verb — never fabricate +/// a task name. +enum ChatThinkingLabel { + static func text(taskTitle: String?, language: AppLanguage) -> String { + let title = taskTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + if let title, !title.isEmpty { + return language == .vi ? "Đang soạn \(title)…" : "Drafting \(title)…" + } + return language == .vi ? "Đang xử lý…" : "Working on it…" + } +} diff --git a/codepet/Models/CopilotMessage.swift b/codepet/Models/CopilotMessage.swift index 05f5e35..807af15 100644 --- a/codepet/Models/CopilotMessage.swift +++ b/codepet/Models/CopilotMessage.swift @@ -2,11 +2,22 @@ import Foundation /// Who authored a Copilot chat message. -enum CopilotRole { case me, companion } +enum CopilotRole: String, Codable { case me, companion } + +/// One line in a run's execute-log — the "how the agent is working" transparency +/// shown while a task is being produced. `done` flips as the step completes. +struct ExecStep: Identifiable, Equatable, Codable { + let id: String + let label: String + var done: Bool + init(id: String = UUID().uuidString, label: String, done: Bool = false) { + self.id = id; self.label = label; self.done = done + } +} /// One Copilot chat message (session-only; not persisted this phase). Named to /// avoid the reflection `ChatMessage`. -struct CopilotMessage: Identifiable, Equatable { +struct CopilotMessage: Identifiable, Equatable, Codable { let id: String let role: CopilotRole /// `var` (not `let`): a streaming companion reply is filled in place, chunk @@ -35,13 +46,25 @@ struct CopilotMessage: Identifiable, Equatable { /// `CompanyStore.handleRunTaskId` while a chat-initiated `run_task` is in /// flight — removed (never persisted) once the run resolves, win or lose. var producing: Bool + /// The specialist companion speaking THIS message (a department handoff); + /// nil = the host/global companion. Drives the orb tint + sender name so a + /// department pet can appear per message. See `deptName`. + var companionId: String? + /// The department this message's specialist leads (e.g. "Marketing"), shown + /// as a "Name · Dept" sender label. nil when spoken by the host. + var deptName: String? + /// The run's execute-log steps (transparency), on the transient `producing` + /// placeholder only. Revealed/completed as the run proceeds; nil = no log. + var execSteps: [ExecStep]? init(id: String = UUID().uuidString, role: CopilotRole, text: String, draft: Deliverable? = nil, draftApproved: Bool = false, firstRunAction: FirstRunAction? = nil, actionConsumed: Bool = false, interview: InterviewGap? = nil, interviewAnswered: Bool = false, navChip: NavAction? = nil, setupSuggestion: SetupAction? = nil, - noted: [RememberedFact]? = nil, producing: Bool = false) { + noted: [RememberedFact]? = nil, producing: Bool = false, + companionId: String? = nil, deptName: String? = nil, + execSteps: [ExecStep]? = nil) { self.id = id self.role = role self.text = text @@ -55,5 +78,8 @@ struct CopilotMessage: Identifiable, Equatable { self.setupSuggestion = setupSuggestion self.noted = noted self.producing = producing + self.companionId = companionId + self.deptName = deptName + self.execSteps = execSteps } } diff --git a/codepet/Models/DepartmentCompanions.swift b/codepet/Models/DepartmentCompanions.swift new file mode 100644 index 0000000..de24dcc --- /dev/null +++ b/codepet/Models/DepartmentCompanions.swift @@ -0,0 +1,31 @@ +// codepet/Models/DepartmentCompanions.swift +import Foundation + +/// Maps each business department to a specialist companion persona. `byte` is the +/// host/generalist and is intentionally NOT assigned to any department, so it can +/// hand off to a specialist. Casting is by domain fit (see PetCharacter.domain) +/// and is freely editable — the handoff mechanic doesn't depend on the exact cast. +enum DepartmentCompanions { + /// deptKey (DepartmentCatalog) → companionId (PetCharacter). + static let map: [String: String] = [ + "eng": "crash", // Backend Dev — builds & ships + "design": "luna", // Designer (UX/UI) + "mkt": "nova", // Firestarter — launches, energy + "sales": "nova", // growth energy (shares the marketing persona) + "support": "sage", // calm, patient, methodical + "fin": "sage", // analytical — "real data, not vibes" + "ops": "glitch", // DevOps — automation + "legal": "glitch", // rules & edges + ] + + static func companionId(for deptKey: String) -> String? { map[deptKey] } + + /// The first department whose NAME appears in `text` (case-insensitive), so a + /// free-text mention ("help me with marketing") can bring in the right pet. + /// Matches on the human name only (not the short key) to avoid substring + /// false positives; the department chip remains the precise trigger. + static func mentionedDeptKey(in text: String) -> String? { + let lower = text.lowercased() + return DepartmentCatalog.all.first { lower.contains($0.name.lowercased()) }?.key + } +} diff --git a/codepet/Models/EnrichInterview.swift b/codepet/Models/EnrichInterview.swift index 7dbc710..72a6712 100644 --- a/codepet/Models/EnrichInterview.swift +++ b/codepet/Models/EnrichInterview.swift @@ -3,7 +3,7 @@ import Foundation /// One of the three plan-shaping brief fields the first-run interview fills, /// in priority order. Verbatim-logic port of the web `Gap` (lib/ai/enrichInterview.ts). -enum InterviewGap: String, CaseIterable, Equatable { +enum InterviewGap: String, CaseIterable, Equatable, Codable { case goal, traction, problem } @@ -45,6 +45,19 @@ enum EnrichInterview { return Array(gapOrder.filter { !filled(value(brief, $0)) }.prefix(maxQuestions)) } + /// The gaps still worth asking when the in-memory interview cursor is gone — + /// the founder quit mid-interview and relaunched into a resumed thread, where + /// the questions live in the transcript but `interviewState` does not. + /// + /// `answered` is what the transcript already shows asked-and-answered, which + /// is NOT the same as what the brief has filled: a SKIPPED question saves + /// nothing, so it stays a gap by `detectGaps` and would be asked again on + /// every relaunch. Subtracting the transcript's answers is what makes a skip + /// stick. + static func remainingGaps(_ brief: CompanyBrief?, answered: [InterviewGap]) -> [InterviewGap] { + detectGaps(brief).filter { !answered.contains($0) } + } + /// Static question + why-line per gap, localized. EN ported from the web /// `QUESTION_FOR`; VI added natively. static func question(for gap: InterviewGap, language: AppLanguage) -> InterviewQuestion { diff --git a/codepet/Models/FirstRunGreeting.swift b/codepet/Models/FirstRunGreeting.swift index c7101bc..8090101 100644 --- a/codepet/Models/FirstRunGreeting.swift +++ b/codepet/Models/FirstRunGreeting.swift @@ -2,7 +2,7 @@ import Foundation /// The one-tap "Do it with me: {task}" action carried by the first-run greeting. -struct FirstRunAction: Equatable { +struct FirstRunAction: Equatable, Codable { let taskId: String let taskTitle: String } diff --git a/codepet/Models/MessageCardStyle.swift b/codepet/Models/MessageCardStyle.swift new file mode 100644 index 0000000..401cdc1 --- /dev/null +++ b/codepet/Models/MessageCardStyle.swift @@ -0,0 +1,35 @@ +// codepet/Models/MessageCardStyle.swift +import SwiftUI + +/// The six interactive message-card kinds and their single semantic hue. +/// Pure + testable. `producing` and plain-text messages are NOT cards (nil). +enum MessageCardKind { case draft, interview, setupSuggestion, firstRunAction, noted, navChip } + +enum MessageCardStyle { + /// Which card kind a message is, from its payload. nil for a producing + /// placeholder or a plain-text message. Precedence (for the pathological + /// multi-payload case, and to pin test behaviour): + /// draft > interview > setupSuggestion > firstRunAction > noted > navChip. + static func kind(for m: CopilotMessage) -> MessageCardKind? { + if m.producing { return nil } + if m.draft != nil { return .draft } + if m.interview != nil { return .interview } + if m.setupSuggestion != nil { return .setupSuggestion } + if m.firstRunAction != nil { return .firstRunAction } + if let noted = m.noted, !noted.isEmpty { return .noted } + if m.navChip != nil { return .navChip } + return nil + } + + /// The single hue that carries the card's meaning. Gold = a decision is owed. + static func hue(for kind: MessageCardKind, companionAccent: Color) -> Color { + switch kind { + case .draft: return CodepetTheme.accentGold + case .interview: return CodepetTheme.accentBlue + case .setupSuggestion: return CodepetTheme.accentTeal + case .firstRunAction: return companionAccent + case .noted: return CodepetTheme.mutedText + case .navChip: return CodepetTheme.hairline + } + } +} diff --git a/codepet/Models/MessageFeedback.swift b/codepet/Models/MessageFeedback.swift new file mode 100644 index 0000000..b80e648 --- /dev/null +++ b/codepet/Models/MessageFeedback.swift @@ -0,0 +1,25 @@ +import Foundation + +/// A per-message reaction (thumb up/down) recorded to the `feedback` collection. +/// Pure — the writer (`CompanyStore.reactToMessage`) adds the server timestamp. +/// `kind: "chat_message"` distinguishes these from FeatureFeedbackManager's +/// feature-rating docs. +struct MessageFeedback: Equatable { + let messageId: String + let helpful: Bool + let companyId: String + let userId: String + let companionId: String + + func firestoreData() -> [String: Any] { + [ + "kind": "chat_message", + "messageId": messageId, + "helpful": helpful, + "companyId": companyId, + "userId": userId, + "companionId": companionId, + "platform": "macos", + ] + } +} diff --git a/codepet/Models/OnboardingContent.swift b/codepet/Models/OnboardingContent.swift index 32f4769..0e68990 100644 --- a/codepet/Models/OnboardingContent.swift +++ b/codepet/Models/OnboardingContent.swift @@ -15,7 +15,6 @@ enum OnboardingContent { Color(red: 242/255, green: 201/255, blue: 76/255 ).opacity(0.22), // 5 Color(red: 126/255, green: 168/255, blue: 255/255).opacity(0.26), // 6 Color(red: 124/255, green: 58/255, blue: 237/255).opacity(0.26), // 7 - Color(red: 124/255, green: 58/255, blue: 237/255).opacity(0.26), // 8 ] /// (display label, stable key) — the numbered single-select on the role step. @@ -60,10 +59,10 @@ enum OnboardingContent { ("Sales", Color(hex: "#7ea8ff")), ("Support", Color(hex: "#7fd694")), ] - /// Per-step left-panel art (STEP_ART), steps 0...8. Steps 0, 7 & 8 reuse ob-team. + /// Per-step left-panel art (STEP_ART), steps 0...7. Steps 0 & 7 reuse ob-team. static let stepArt = [ "ob-team", "ob-couch", "ob-chess", "ob-drummer", - "ob-observatory", "ob-isometric", "ob-boardroom", "ob-team", "ob-team", + "ob-observatory", "ob-isometric", "ob-boardroom", "ob-team", ] static let analysisLines = [ "Reading what you told me…", @@ -73,8 +72,13 @@ enum OnboardingContent { ] /// Total onboarding screens INCLUDING the cinematic cold-open (step 0). The footer /// counts `step + 1` of `total` — matching the web (`OB_TOTAL`), where the cold-open - /// is counted but renders no footer, so the first question reads "Step 2 of 9". - static let total = 9 + /// is counted but renders no footer, so the first question reads "Step 2 of 8". + /// + /// 8, not the web's 9: the companion-picker step was cut. It asked the founder to + /// choose a pet before they had met any of them, which is a decision without + /// information — so the default companion carries through and the picker lives in + /// Settings, where it can be changed once the choice actually means something. + static let total = 8 static let defaultStageIndex = 2 /// Web CSS theme vars that CodepetTheme doesn't already expose, mapped 1:1. @@ -85,6 +89,12 @@ enum OnboardingContent { static let accentDeep = Color.dyn("#5b27b0", "#7c3aed") // --accent-deep static let accentTint = Color.dyn("#eee6fd", "#271f3a") // --accent-tint static let accentLine = Color.dyn("#d9c9f7", "#43356b") // --accent-line + // Stage-slider ticks. The web hardcodes these (`.sb-tick` #dad3c5 / .major + // #cbc3b2) with no dark override, so they glare against a dark panel. Light + // values kept identical; dark values added — a deliberate divergence, sitting + // between --hairline and --t-4 so the ticks stay legible without shouting. + static let tickMinor = Color.dyn("#dad3c5", "#3a332a") + static let tickMajor = Color.dyn("#cbc3b2", "#4a4238") static let coldBg = Color(hex: "#100a26") // cold-open / splash — STAYS dark } } diff --git a/codepet/Models/RoadmapEngine.swift b/codepet/Models/RoadmapEngine.swift index 3380779..1aa9e9f 100644 --- a/codepet/Models/RoadmapEngine.swift +++ b/codepet/Models/RoadmapEngine.swift @@ -43,6 +43,30 @@ enum RoadmapEngine { })?.element } + /// Up to `limit` parallelizable "next moves" for the chat fan-out: the first + /// `codepetCanDo` task in each DISTINCT department that maps to a specialist + /// companion, in roadmap order (phase order, then array position). Pure. + static func nextMoves(_ tasks: [RoadmapTask], limit: Int) -> [RoadmapTask] { + guard limit > 0 else { return [] } + let ordered = tasks.enumerated().sorted { a, b in + a.element.phase.order != b.element.phase.order + ? a.element.phase.order < b.element.phase.order + : a.offset < b.offset + }.map { $0.element } + var seenDepts = Set() + var out: [RoadmapTask] = [] + for task in ordered { + guard let dept = task.dept, + DepartmentCompanions.companionId(for: dept) != nil, + !seenDepts.contains(dept), + status(for: task, in: tasks) == .codepetCanDo else { continue } + seenDepts.insert(dept) + out.append(task) + if out.count == limit { break } + } + return out + } + static func progressPercent(_ tasks: [RoadmapTask]) -> Int { guard !tasks.isEmpty else { return 0 } let done = tasks.filter { $0.done }.count diff --git a/codepet/Services/ChatPersistence.swift b/codepet/Services/ChatPersistence.swift new file mode 100644 index 0000000..d27f804 --- /dev/null +++ b/codepet/Services/ChatPersistence.swift @@ -0,0 +1,66 @@ +// codepet/Services/ChatPersistence.swift +import Foundation +import FirebaseFirestore + +/// Persists chat threads to a per-account subcollection — +/// `companies/{uid}/threads/{threadId}` — one doc per thread, so a busy thread +/// only rewrites itself and no single doc approaches Firestore's 1MB limit. +/// Encodes the Codable `ChatThread` via JSONEncoder → JSONSerialization (same +/// path as `CompanyData`'s field writes). All ops are fail-soft. +enum ChatPersistence { + /// Cap the hydrated Recent list so launch stays cheap; older threads remain + /// in Firestore but aren't loaded. + static let loadLimit = 50 + + private static func collection(_ companyId: String) -> CollectionReference { + Firestore.firestore().collection("companies").document(companyId).collection("threads") + } + + /// Pure — testable without Firestore. The thread is persisted as its + /// `.persistable` projection (transient run state stripped). + static func threadPayload(_ thread: ChatThread) -> [String: Any] { + guard let data = try? JSONEncoder().encode(thread.persistable), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return [:] } + return dict + } + + /// Write one thread. Fail-soft: false on error / empty payload. + static func saveThread(companyId: String, thread: ChatThread) async -> Bool { + let payload = threadPayload(thread) + guard !payload.isEmpty else { return false } + do { + try await collection(companyId).document(thread.id).setData(payload, merge: true) + return true + } catch { + return false + } + } + + /// Delete one thread. Fail-soft. + static func deleteThread(companyId: String, threadId: String) async -> Bool { + do { + try await collection(companyId).document(threadId).delete() + return true + } catch { + return false + } + } + + /// Load the most-recent threads (newest first). Fail-soft to []. Decodes each + /// doc back to a `ChatThread`; a doc that fails to decode is skipped, not fatal. + static func loadThreads(companyId: String) async -> [ChatThread] { + do { + let snap = try await collection(companyId) + .order(by: "updatedAt", descending: true) + .limit(to: loadLimit) + .getDocuments() + return snap.documents.compactMap { doc in + guard let data = try? JSONSerialization.data(withJSONObject: doc.data()) else { return nil } + return try? JSONDecoder().decode(ChatThread.self, from: data) + } + } catch { + return [] + } + } +} diff --git a/codepet/Services/CompanyChatClient.swift b/codepet/Services/CompanyChatClient.swift index ad0b270..7193cf3 100644 --- a/codepet/Services/CompanyChatClient.swift +++ b/codepet/Services/CompanyChatClient.swift @@ -176,6 +176,9 @@ enum CompanyChatClient { static let endpoint = URL(string: "https://us-central1-devpet-8f4b1.cloudfunctions.net/companyChat")! static func send(_ req: CompanyChatRequest) async -> CompanyChatReply? { + #if DEBUG + if MockChat.enabled { return await MockChat.reply(req) } + #endif guard let token = try? await Auth.auth().currentUser?.getIDToken() else { return nil } var urlRequest = URLRequest(url: endpoint) urlRequest.httpMethod = "POST" @@ -210,6 +213,9 @@ enum CompanyChatClient { session: URLSession = .shared, authTokenProvider: (() async throws -> String)? = nil ) -> AsyncThrowingStream { + #if DEBUG + if MockChat.enabled { return MockChat.stream(req) } + #endif let capturedSession = session let capturedAuthTokenProvider = authTokenProvider ?? { guard let token = try? await Auth.auth().currentUser?.getIDToken() else { diff --git a/codepet/Services/CompanyData.swift b/codepet/Services/CompanyData.swift index 71b37e8..021eb24 100644 --- a/codepet/Services/CompanyData.swift +++ b/codepet/Services/CompanyData.swift @@ -167,6 +167,9 @@ enum CompanyData { /// non-200 / unreachable — `generateRoadmap` treats `[]` as "no change", so the board /// is never clobbered. static func fetchRoadmap(brief: CompanyBrief, language: AppLanguage) async -> [RoadmapTask] { + #if DEBUG + if MockChat.enabled { return MockChat.roadmap() } + #endif guard let token = try? await Auth.auth().currentUser?.getIDToken() else { return [] } var req = URLRequest(url: roadmapEndpoint) req.httpMethod = "POST" @@ -191,6 +194,9 @@ enum CompanyData { /// fail-soft to `.empty` — convert such fields to a JSON-safe /// representation (e.g. epoch seconds) before adding them to the doc. static func load(companyId: String) async -> CompanyState { + #if DEBUG + if MockChat.enabled { return MockChat.company() } + #endif let db = Firestore.firestore() do { let snap = try await db.collection("companies").document(companyId).getDocument() diff --git a/codepet/Services/MockChat.swift b/codepet/Services/MockChat.swift new file mode 100644 index 0000000..b2dc27e --- /dev/null +++ b/codepet/Services/MockChat.swift @@ -0,0 +1,438 @@ +// codepet/Services/MockChat.swift +#if DEBUG +import Foundation + +/// Dev-only chat stub. When the `CODEPET_MOCK_CHAT` flag is set (launch arg +/// `-CODEPET_MOCK_CHAT YES` or the UserDefaults key), `CompanyChatClient` and +/// `RunTaskClient` short-circuit to canned local responses — so the whole +/// redesigned chat experience (streaming orb, un-bubbled messages, thumbs, +/// run→produce→approve cards, nav/setup/remember chips) can be exercised with +/// ZERO Anthropic spend. +/// +/// Compiled ONLY under `#if DEBUG`; the flag defaults off, so a normal build +/// still hits the real Cloud Functions. It is a KEYWORD ROUTER — the word you +/// type decides which affordance fires (see `route`), giving a deterministic +/// full-coverage test conversation: +/// "run …" → run a real open task → inline draft card (Approve/Open/Redo) +/// "remember …" → auto-merge a decision → "Noted" chip +/// "connect …" → suggest enabling an off toolkit item → enable card +/// "roadmap …" → a tappable "go to Roadmap" nav chip +/// "long …" → a long multi-paragraph reply (rendering / scroll) +/// anything else → a normal streamed reply +/// +/// Toggle from the CLI (or just relaunch with the launch arg): +/// defaults write app.murror.codepet CODEPET_MOCK_CHAT -bool YES # on +/// defaults write app.murror.codepet CODEPET_MOCK_CHAT -bool NO # off +enum MockChat { + static var enabled: Bool { UserDefaults.standard.bool(forKey: "CODEPET_MOCK_CHAT") } + + /// Decide the reply text + which `.done` action fires, from the message text + /// and the request's own `runnable`/`envSetup` lists (so echoed ids are valid). + private static func route(_ req: CompanyChatRequest) -> (text: String, action: ChatDoneAction) { + let msg = req.userMessage.lowercased() + + // summarize → a grounded read of where the project stands (guided flow start). + if msg.contains("summar") { + return (""" + Here\u{2019}s where Codepet stands. You\u{2019}re **building** \u{2014} the product exists, and the job \ + now is getting it in front of real users. Your foundation is mostly in place; what\u{2019}s \ + missing is the launch surface (landing, waitlist) and the go-to-market basics (pricing, \ + outreach). + + **The one thing that matters this week:** one real signal from one real user \u{2014} \ + everything on your roadmap should serve that. + + Ask me **\u{201C}What should I focus on now?\u{201D}** and I\u{2019}ll point you at the single next move. + """, ChatDoneAction()) + } + + // run → produce → approve (one task at a time). Pick the runnable whose TITLE + // shares a 4+ char word with the message (so "run privacy"/"run faq" hit that + // department's task); a bare "run it" falls back to the first (the next move). + // The reply previews the NEXT step so the guided flow keeps moving. + if msg.contains("run") || msg.contains("draft") || msg.contains("produce") { + let picked = req.runnable.first { r in + r.title.lowercased().split(whereSeparator: { !$0.isLetter }).contains { w in + w.count >= 4 && msg.contains(String(w)) + } + } ?? req.runnable.first + if let task = picked { + let after = req.runnable.first { $0.id != task.id } + let nextLine = after.map { + "\n\nOnce you approve this, your next move is **\($0.title)** (\(deptName(forRunnableId: $0.id))) \u{2014} ask \u{201C}what\u{2019}s next?\u{201D}" + } ?? "\n\nThat\u{2019}s your last open move \u{2014} nice work." + return ("On it \u{2014} drafting \u{201C}\(task.title)\u{201D} for you now\u{2026}\(nextLine)", + ChatDoneAction(runTaskId: task.id)) + } + return ("You don\u{2019}t have a task I can run right now — everything\u{2019}s either done or waiting on you.", + ChatDoneAction()) + } + + // focus / what's next → suggest ONE specific next task in its department, with + // a why + an offer to run it. The core of the guided, one-at-a-time flow. + if msg.contains("focus") || msg.contains("what should i") || msg.contains("next") + || msg.contains("guide me") || msg.contains("where do i start") { + if let next = req.runnable.first { + let dept = deptName(forRunnableId: next.id) + let after = req.runnable.dropFirst().first + let afterLine = after.map { "\n\nAfter that, the next step will be **\($0.title)**." } ?? "" + return (""" + Focus on one thing: **\(next.title)** \u{2014} that\u{2019}s a **\(dept)** move, and it\u{2019}s the \ + highest-leverage next step on your roadmap right now. + + Say **\u{201C}run it\u{201D}** and I\u{2019}ll draft it with you.\(afterLine) + """, ChatDoneAction()) + } + return ("You\u{2019}re all caught up \u{2014} nothing I can run right now. Everything\u{2019}s either done or waiting on you.", + ChatDoneAction()) + } + + // remember → auto-merged decision + "Noted" chip. + if msg.contains("remember") || msg.contains("note that") { + let fact = RememberedFact(topic: "Founder note", + statement: req.userMessage.trimmingCharacters(in: .whitespacesAndNewlines)) + return ("Got it — I\u{2019}ll remember that.", ChatDoneAction(remember: [fact])) + } + + // setup → suggest enabling an off toolkit item. + if msg.contains("connect") || msg.contains("enable") || msg.contains("setup") || msg.contains("turn on") { + if let tool = req.envSetup.first { + return ("Want me to turn on \(tool.name)? Tap to enable it.", + ChatDoneAction(setup: SetupAction(category: tool.category, name: tool.name))) + } + return ("Everything in your toolkit is already on — nothing to connect.", ChatDoneAction()) + } + + // nav → a tappable "go here" chip. + if msg.contains("roadmap") || msg.contains("go to") || msg.contains("open ") { + return ("Here\u{2019}s your roadmap — tap to jump over.", + ChatDoneAction(nav: NavAction(destination: "roadmap", target: nil))) + } + + // long → multi-paragraph rendering / scroll. + if msg.contains("long") { + return (""" + Here's the fuller version. The trap at your stage isn't a lack of ideas — it's \ + spending another week refining instead of getting one real signal. So the whole \ + game right now is compressing the loop between 'I think this is true' and 'a real \ + user just showed me it's true (or not).' + + Concretely, that's three moves. First, put your value into one sentence a stranger \ + gets in five seconds and stick it somewhere public — a landing hero is fine. Second, \ + book five 20-minute calls with people who actually have the problem; you're there to \ + learn, not pitch. Third, ship the smallest thing they can touch — a rough version \ + teaches you more than a polished mock ever will. + + The reason this order matters: positioning tells you what to build, the calls tell \ + you if anyone cares, and shipping tells you the truth. Skip the middle and you'll \ + build something beautiful that no one asked for. + + Want me to draft the positioning sentence, or the outreach message for those calls? + """, ChatDoneAction()) + } + + // default reply. + return (""" + Here's how I'd think about it. Your leverage right now is momentum, not polish — the \ + goal this week is one real signal from one real user, not a perfect plan. + + So: (1) write your positioning in a single sentence and put it where a stranger can \ + see it, (2) book five short calls with people who have the problem, and (3) ship the \ + smallest thing they can actually touch. Do those three and you'll know more by Friday \ + than another month of planning would tell you. + + Want me to draft the positioning line or the outreach message to get those calls booked? + """, ChatDoneAction()) + } + + /// Department display name for a runnable task id (mock roadmap lookup) — used + /// by the guided flow to name the department a suggested task belongs to. + private static func deptName(forRunnableId id: String) -> String { + guard let key = roadmap().first(where: { $0.id == id })?.dept, + let name = DepartmentCatalog.find(key)?.name else { return "your team" } + return name + } + + /// Non-streaming counterpart of `stream`. + static func reply(_ req: CompanyChatRequest) async -> CompanyChatReply? { + try? await Task.sleep(nanoseconds: 300_000_000) + let (raw, action) = route(req) + // Chat bubbles render plain text (not markdown), so drop bold markers. + let text = raw.replacingOccurrences(of: "**", with: "") + return CompanyChatReply(text: text, runTaskId: action.runTaskId, nav: action.nav, + setup: action.setup, remember: action.remember) + } + + /// Streams the routed reply word-by-word (with small delays) then a `.done` + /// frame carrying the routed action — mirroring the real CF's SSE shape so + /// the store's streaming path is exercised end-to-end. + static func stream(_ req: CompanyChatRequest) -> AsyncThrowingStream { + let (rawFull, action) = route(req) + // Chat bubbles render plain text (not markdown), so drop bold markers. + let full = rawFull.replacingOccurrences(of: "**", with: "") + return AsyncThrowingStream { continuation in + let task = Task.detached { + try? await Task.sleep(nanoseconds: 500_000_000) // "Working on it…" beat + var chunk = "" + for ch in full { + chunk.append(ch) + if ch == " " { + continuation.yield(.delta(chunk)) + chunk = "" + try? await Task.sleep(nanoseconds: 45_000_000) + } + } + if !chunk.isEmpty { continuation.yield(.delta(chunk)) } + continuation.yield(.done(model: "mock", cacheHit: false, action: action)) + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Canned deliverable for `RunTaskClient.run` — a `run_task_id` produces a + /// realistic inline draft card (Approve / Open / Redo) offline, tailored to + /// the task so demo screenshots read like genuine Codepet output. + static func runResult(_ req: RunTaskRequest) async -> RunTaskResponse? { + try? await Task.sleep(nanoseconds: 700_000_000) // "producing…" beat + let (kind, body) = deliverable(for: req.taskTitle) + let note = req.reviseNote.map { "\n\n_Revised per your note: \($0)_" } ?? "" + return RunTaskResponse(kind: kind, title: req.taskTitle, body: body + note, payload: nil) + } + + /// A realistic deliverable (kind + markdown body) tailored to the task title. + private static func deliverable(for title: String) -> (kind: String, body: String) { + let t = title.lowercased() + if t.contains("positioning") || t.contains("value prop") { + return ("doc", """ + For solo founders drowning in scattered docs and stalled momentum, **Codepet** is the \ + AI cofounder that turns your company into one living workspace — it plans your next \ + move, does the work with you, and remembers every decision. Unlike a generic AI chat \ + or a stack of disconnected tools, Codepet is grounded in *your* company and acts, not \ + just answers. + + **One-liner** — Codepet is the AI cofounder that runs your company's busywork so you \ + can build. + + **Why it works** + - Names a sharp, real pain (scattered context, no momentum). + - Says exactly who it's for (early solo founders), not 'everyone'. + - Draws the contrast (grounded + acts vs. generic chat). + + **Use it in**: your landing hero, the first line of your pitch, the App Store subtitle. + """) + } + if t.contains("landing") || t.contains("copy") || t.contains("website") { + return ("post", """ + **Headline** — Your AI cofounder, not another chatbot. + + **Subhead** — Codepet plans your next move, does the work with you, and remembers \ + every decision — grounded in your actual company. + + **Benefit bullets** + - **Always knows your context.** No re-explaining — it reads your brief, roadmap, and decisions. + - **Does the work, not just talk.** Drafts, plans, and deliverables you approve in one tap. + - **A team of specialists.** Marketing, Engineering, and Design pets step in for their domain. + + **Primary CTA** — Start free · **Secondary** — See how it works + """) + } + if t.contains("waitlist") || t.contains("signup") || t.contains("sign up") { + return ("checklist", """ + A no-backend email capture you can ship today. + + 1. **Pick the tool** — a hosted form (Tally / Typeform) or a one-field section on your landing page. + 2. **Ask for one thing** — email only. Every extra field drops conversion. + 3. **Set the confirmation** — a short 'you're on the list' message + what to expect next. + 4. **Wire the storage** — form → a sheet or your Firestore `waitlist` collection. + 5. **Add a share nudge** — 'Want in sooner? Share your link.' + + **Done when**: a stranger can land, drop an email, and you can see it come through. + """) + } + if t.contains("brand") || t.contains("logo") || t.contains("look") || t.contains("visual") { + return ("doc", """ + A calm, confident visual direction you can apply everywhere. + + **Palette** — one ink (`#1f1b15`), one paper (`#f4f1ea`), one accent (a violet, `#7c3aed`). \ + Restraint reads as premium; add a second accent only when you truly need it. + + **Type** — a friendly grotesk for headers, a readable sans for body. One family, two weights beats five fonts. + + **Logo mark** — a simple, single-color glyph that survives at 16px (favicon) and in one color \ + (stamps, watermarks). Wordmark = the name set in your header weight, tightened. + + **Rule of thumb** — pick the boring, consistent option; recognizability comes from repetition, not novelty. + """) + } + if t.contains("outreach") || t.contains("cold") || t.contains("sales") || t.contains("email") { + return ("email", """ + **Subject** — a quick idea for {{company}} + + Hi {{name}} — I'll keep this short. I'm building Codepet, an AI cofounder that runs a \ + solo founder's whole company — it plans the next move, does the work with you, and \ + remembers every decision. + + I noticed {{company}} is {{specific observation}} — that's exactly the kind of momentum \ + problem it's built for. Worth a 15-minute look? + + No pitch deck, just a live demo. Reply "yes" and I'll send a time. + + — Mona + + _Tip: keep the ask tiny (a 15-min look), personalize line 2, and cut everything else._ + """) + } + if t.contains("faq") || t.contains("support") || t.contains("help center") { + return ("doc", """ + The first questions new users ask — answer them before they have to write in. + + **What is Codepet?** An AI cofounder that runs your company's busywork — it plans, does \ + the work with you, and remembers your decisions. + + **Do I need to know how to code?** No. Codepet drafts and produces; you approve. + + **Is my data private?** Your company context is yours; we don't train on it. + + **How is it priced?** A credit model — chat feels free, deliverables spend. Trial, then Pro. + + **Can I cancel anytime?** Yes, one tap in Settings — no email required. + + _Add each real question you get to this list; it doubles as your objection-handling script._ + """) + } + if t.contains("deploy") || t.contains("release") || t.contains("ops") { + return ("checklist", """ + Ship a release the same safe way every time. + + 1. **Green build** — tests pass locally and in CI. + 2. **Version bump** — tag the release; write a one-line changelog. + 3. **Backup / migration** — run pending DB migrations; confirm a rollback path. + 4. **Deploy to staging** — smoke-test the critical flow end to end. + 5. **Promote to prod** — deploy, then re-run the smoke test live. + 6. **Watch** — check errors/latency for 15 minutes; keep the rollback command ready. + + **Done when**: the critical flow works in prod and dashboards are clean. + """) + } + if t.contains("privacy") || t.contains("policy") || t.contains("legal") || t.contains("terms") { + return ("legal", """ + _Plain-language draft — have a lawyer review before you publish._ + + **Privacy Policy — Codepet** + + **What we collect** — your account email, the company context you enter, and basic usage \ + analytics. We do **not** sell your data or train public models on your company content. + + **Why** — to run the product for you (generate work, remember decisions) and to keep it reliable. + + **Your choices** — export or delete your data anytime from Settings; deleting your account \ + removes your company content. + + **Sharing** — only with the infrastructure providers needed to run the service (hosting, \ + auth, payments), under their own terms. + + **Contact** — privacy@codepet.app for any request. + """) + } + if t.contains("pricing") || t.contains("price") { + return ("doc", """ + **Model** — credits, not a seat/day cap: chat feels unlimited, deliverables spend. + + - **Trial** — 7 days, ~150 credits, then it stops (no permanent free tier). + - **Pro — $20/mo** — 800 credits included; overage auto-billed at $0.05/credit. + + **Why this shape** + - Chat is cheap (~0.25 credit/msg) so exploring feels free; the real cost is generation. + - One price, no BYOK — simpler to reason about and simpler to sell. + + You can change any number later — ship a price, then let real usage tell you. + """) + } + if t.contains("user") || t.contains("interview") || t.contains("talk to") || t.contains("discovery") { + return ("doc", """ + **Goal** — understand the problem, not pitch. 20 minutes each. + + **Opening** — 'I'm trying to learn, not sell. Walk me through the last time you ran into this.' + + **Core questions** + 1. When did you last hit this? What did you actually do? + 2. What's the most frustrating part — and why that part? + 3. What have you tried? What did it cost you (time or money)? + 4. If you had a magic fix, what would it do for you? + + **Close** — 'Who else should I talk to?' and ask to follow up. + + **Watch for**: stories about real behavior over opinions ('I would probably…'). + """) + } + // default: a crisp weekly plan. + return ("plan", """ + A first cut to get you moving — treat it as a starting point, not the final word. + + **The move** — take '\(title)' from idea to a concrete first step this week. + + **This week** + 1. Define what 'done' looks like in a single sentence. + 2. Ship the smallest version today — rough is fine. + 3. Get one piece of real feedback before you polish anything. + + **Watch out for** — scope creep and polishing before you've validated the core. + """) + } + + /// A fully canned, onboarded company for `CompanyData.load` — so mock mode is + /// self-contained (no Firestore, no real account needed) and the fan-out has a + /// deterministic roadmap with THREE distinct runnable departments (mkt / eng / + /// design) → "Run my next moves" fans out to 3 parallel agents, all offline. + static func company() -> CompanyState { + var brief = CompanyBrief() + brief.founderName = "Mona" + brief.projectName = "Codepet" + brief.oneLiner = "Your AI cofounder that runs the whole company with you." + brief.stage = "building" + return CompanyState(brief: brief, departments: [], library: [], stage: .building, + companionId: "byte", onboardedAt: Date(), tasks: roadmap()) + } + + /// Canned roadmap for `CompanyData.fetchRoadmap` — a realistic mix so the + /// board, the chat's `runnable` list (Codepet-can-do tasks), and the + /// "needs you" landing card all have something to show offline. The three + /// `.draft`/`.does` tasks with no deps resolve to `codepetCanDo` → runnable. + static func roadmap() -> [RoadmapTask] { + [ + // ONE Codepet-can-do task per department (all 8) so every department's + // card is testable. Each title carries a keyword ("run ") the + // mock router matches to this task; each maps to a tailored deliverable. + RoadmapTask(id: "mock-brand", title: "Design your brand look", + detail: "A simple visual direction — colors, type, and a logo mark.", + phase: .foundation, who: .draft, dept: "design"), // run brand + RoadmapTask(id: "mock-landing", title: "Write your landing page copy", + detail: "Headline, subhead, and three benefit bullets.", + phase: .foundation, who: .draft, dept: "mkt"), // run landing + RoadmapTask(id: "mock-pricing", title: "Draft a simple pricing plan", + detail: "A starting price + model you can change later.", + phase: .foundation, who: .draft, dept: "fin"), // run pricing + RoadmapTask(id: "mock-waitlist", title: "Set up a waitlist signup", + detail: "A simple email capture so early interest isn't lost.", + phase: .build, who: .does, dept: "eng"), // run waitlist + RoadmapTask(id: "mock-outreach", title: "Write a cold outreach email", + detail: "A short first-touch email to a potential customer.", + phase: .build, who: .draft, dept: "sales"), // run outreach + RoadmapTask(id: "mock-faq", title: "Draft a support FAQ", + detail: "Answers to the first questions new users will ask.", + phase: .build, who: .draft, dept: "support"), // run faq + RoadmapTask(id: "mock-deploy", title: "Set up a deploy checklist", + detail: "The steps to ship a release safely, every time.", + phase: .ship, who: .does, dept: "ops"), // run deploy + RoadmapTask(id: "mock-privacy", title: "Draft a privacy policy", + detail: "A plain-language policy covering what you collect and why.", + phase: .ship, who: .draft, dept: "legal"), // run privacy + // A "needs you" task so the landing card + roadmap show that state too. + RoadmapTask(id: "mock-interviews", title: "Talk to 5 potential users", + detail: "Book and run five short discovery calls this week.", + phase: .find, who: .you, dept: "mkt"), + ] + } +} +#endif diff --git a/codepet/Services/RunTaskClient.swift b/codepet/Services/RunTaskClient.swift index 6612ab2..f8b71c2 100644 --- a/codepet/Services/RunTaskClient.swift +++ b/codepet/Services/RunTaskClient.swift @@ -47,6 +47,9 @@ enum RunTaskClient { static let endpoint = URL(string: "https://us-central1-devpet-8f4b1.cloudfunctions.net/runTask")! static func run(_ req: RunTaskRequest) async -> RunTaskResponse? { + #if DEBUG + if MockChat.enabled { return await MockChat.runResult(req) } + #endif guard let token = try? await Auth.auth().currentUser?.getIDToken() else { return nil } var urlRequest = URLRequest(url: endpoint) urlRequest.httpMethod = "POST" diff --git a/codepet/Views/CodepetTheme.swift b/codepet/Views/CodepetTheme.swift index 6e4fb24..caf898f 100644 --- a/codepet/Views/CodepetTheme.swift +++ b/codepet/Views/CodepetTheme.swift @@ -52,6 +52,9 @@ enum CodepetTheme { static let accentOrange = Color.dyn("#ff8c42", "#ff9b5e") static let accentBlue = Color.dyn("#2563eb", "#6ea8ff") + static let chatCanvas = Color.dyn("#f8f7f3", "#16130f") // matches pageBackground + static let chatOrbCore = Color.dyn("#0E0A16", "#040208") // the orb's luminous core + // MARK: Geometry /// Default card corner radius — gentle ~14pt curve on stat cards and @@ -434,3 +437,27 @@ extension Color { Color(nsColor: CodepetTheme.dynamicNSColor(light: light, dark: dark)) } } + +extension CodepetTheme { + /// Readable text color for content sitting ON an accent/companion fill — the + /// native port of the web's `--on-accent`: dark ink on LIGHT fills (gold, teal, + /// bright companion hues), white on DARK fills (purple, blue). Resolves the + /// fill's luminance PER drawing appearance, so a `Color.dyn` accent that differs + /// light↔dark still gets the right ink in each theme. Fixes low-contrast + /// white-on-gold / white-on-bright-green. + static func onAccent(_ fill: Color) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + var lum = 0.0 + appearance.performAsCurrentDrawingAppearance { + if let c = NSColor(fill).usingColorSpace(.sRGB) { + // Relative luminance (sRGB coefficients). + lum = 0.2126 * Double(c.redComponent) + + 0.7152 * Double(c.greenComponent) + + 0.0722 * Double(c.blueComponent) + } + } + // Threshold ~0.6: light fills → near-black ink, dark fills → white. + return lum > 0.6 ? NSColor(hex: "#1f1b15") : NSColor(hex: "#ffffff") + }) + } +} diff --git a/codepet/Views/Copilot/AgentsWorkingRow.swift b/codepet/Views/Copilot/AgentsWorkingRow.swift new file mode 100644 index 0000000..7137209 --- /dev/null +++ b/codepet/Views/Copilot/AgentsWorkingRow.swift @@ -0,0 +1,154 @@ +import SwiftUI + +/// Status of one concurrent department-agent run. +enum AgentRunStatus: String, Equatable, Codable, CaseIterable { + case working, reviewing, done, failed + + func label(_ lang: AppLanguage) -> String { + switch (self, lang) { + case (.working, .vi): return "Đang làm" + case (.working, _): return "Working" + case (.reviewing, .vi): return "Đang duyệt" + case (.reviewing, _): return "Reviewing" + case (.done, .vi): return "Xong" + case (.done, _): return "Done" + case (.failed, .vi): return "Lỗi" + case (.failed, _): return "Failed" + } + } +} + +/// One department agent working on a task, for the inline multi-agent exec-log. +/// A multi-agent analogue of what `ExecLogRow` shows for a single run. +struct AgentRun: Identifiable, Equatable { + let id: String + let companionId: String // resolves avatar + accent via PetCharacter.all + let deptName: String // "Engineering", "Design", … + let taskTitle: String + var steps: [ExecStep] // reuses the existing ExecStep type + var status: AgentRunStatus + let startedAt: Date // for elapsed display + + init(id: String = UUID().uuidString, companionId: String, deptName: String, + taskTitle: String, steps: [ExecStep], status: AgentRunStatus, startedAt: Date) { + self.id = id + self.companionId = companionId + self.deptName = deptName + self.taskTitle = taskTitle + self.steps = steps + self.status = status + self.startedAt = startedAt + } + + /// "4/7" — done steps over total. + var stepCounter: String { "\(steps.filter { $0.done }.count)/\(steps.count)" } + + /// The first not-done step (the spinning one); nil when all are done. + var currentStepIndex: Int? { steps.firstIndex { !$0.done } } + + /// "m:ss" elapsed since `startedAt`. `now` is injected so it is testable and + /// preview-stable (no `Date()` read inside the view). + func elapsedString(now: Date) -> String { + let secs = max(0, Int(now.timeIntervalSince(startedAt))) + return String(format: "%d:%02d", secs / 60, secs % 60) + } +} + +/// Inline, in-chat view of MULTIPLE department agents working at once — a +/// multi-agent sibling of `ExecLogRow`, modeled on Codex's run list. One card +/// holds a stacked row per active agent; left-aligned like a companion message. +struct AgentsWorkingRow: View { + let runs: [AgentRun] + /// Injected clock for elapsed display — stable in previews/tests. + var now: Date = Date() + + @Environment(\.uiLanguage) private var lang + + var body: some View { + HStack { + MessageCard(hue: CodepetTheme.accentPurple) { + VStack(alignment: .leading, spacing: 12) { + Text((lang == .vi ? "Đang làm việc" : "Agents at work") + + " · \(runs.count)") + .font(CodepetTheme.inter(10, weight: .semibold)) + .tracking(0.5) + .foregroundColor(CodepetTheme.mutedText) + ForEach(Array(runs.enumerated()), id: \.element.id) { idx, run in + if idx > 0 { Divider().overlay(CodepetTheme.hairline) } + agentRow(run) + } + } + } + Spacer(minLength: 24) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder private func agentRow(_ run: AgentRun) -> some View { + let persona = PetCharacter.all[run.companionId] + let accent = persona?.color ?? CodepetTheme.accentPurple + let name = persona?.name ?? "Codepet" + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 8) { + CompanionAvatar(companionId: run.companionId, size: 22, + isWorking: run.status == .working) + Text("\(name) · \(run.deptName)") + .font(CodepetTheme.inter(13, weight: .semibold)) + .foregroundColor(accent) + Spacer(minLength: 8) + statusPill(run.status) + Text(run.elapsedString(now: now)) + .font(CodepetTheme.inter(11)) + .foregroundColor(CodepetTheme.mutedText) + Text(run.stepCounter) + .font(CodepetTheme.inter(11, weight: .medium)) + .foregroundColor(CodepetTheme.mutedText) + } + Text(run.taskTitle) + .font(CodepetTheme.inter(14)) + .foregroundColor(CodepetTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + ForEach(Array(run.steps.enumerated()), id: \.element.id) { idx, step in + HStack(spacing: 8) { + stepIcon(done: step.done, isCurrent: idx == run.currentStepIndex) + .frame(width: 15, height: 15) + Text(step.label) + .font(CodepetTheme.inter(12)) + .foregroundColor(step.done ? CodepetTheme.bodyText + : (idx == run.currentStepIndex ? CodepetTheme.primaryText + : CodepetTheme.mutedText)) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private func statusPill(_ status: AgentRunStatus) -> some View { + let fg: Color + let bg: Color + switch status { + case .working: fg = CodepetTheme.accentPurple; bg = CodepetTheme.accentPurple.opacity(0.14) + case .reviewing: fg = CodepetTheme.accentGold; bg = CodepetTheme.accentGold.opacity(0.16) + case .done: fg = CodepetTheme.accentTeal; bg = CodepetTheme.accentTeal.opacity(0.16) + case .failed: fg = Color.red; bg = Color.red.opacity(0.14) + } + return Text(status.label(lang)) + .font(CodepetTheme.inter(10, weight: .semibold)) + .foregroundColor(fg) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Capsule().fill(bg)) + } + + // Mirrors ExecLogRow's step icon: done → teal check, current → spinner, else dim ring. + @ViewBuilder private func stepIcon(done: Bool, isCurrent: Bool) -> some View { + if done { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 13)).foregroundColor(CodepetTheme.accentTeal) + } else if isCurrent { + ProgressView().controlSize(.small).scaleEffect(0.7) + } else { + Image(systemName: "circle") + .font(.system(size: 12)).foregroundColor(CodepetTheme.mutedText.opacity(0.45)) + } + } +} diff --git a/codepet/Views/Copilot/ChatBackdrop.swift b/codepet/Views/Copilot/ChatBackdrop.swift new file mode 100644 index 0000000..6e8995e --- /dev/null +++ b/codepet/Views/Copilot/ChatBackdrop.swift @@ -0,0 +1,22 @@ +import SwiftUI + +/// The chat surface's ambient purple radial wash — one shared, inert decoration +/// placed behind BOTH the empty hero and the active conversation so the two read +/// as one continuous surface. Suppressed under Reduce Transparency. Extracted from +/// `ChatEmptyState.brandWash` so the empty and active states share one definition. +struct ChatBackdrop: View { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + var body: some View { + Group { + if !reduceTransparency { + RadialGradient( + gradient: Gradient(colors: [CodepetTheme.accentPurple.opacity(0.16), .clear]), + center: .center, startRadius: 0, endRadius: 420) + .blur(radius: 60) + .allowsHitTesting(false) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/codepet/Views/Copilot/ChatComposer.swift b/codepet/Views/Copilot/ChatComposer.swift new file mode 100644 index 0000000..3ac8355 --- /dev/null +++ b/codepet/Views/Copilot/ChatComposer.swift @@ -0,0 +1,196 @@ +import SwiftUI + +/// The chat composer — one reusable input surface used in BOTH the empty hero +/// and the docked active conversation. Owns no state: draft/mode live in the +/// parent (`CopilotChatView`) so the same value drives both placements. +/// +/// Honesty notes: the `+` button is a quick-actions menu (NOT a file picker — +/// the app has no attachments), and the mode control shapes the outgoing message +/// via `ChatMode` (no backend mode exists). +struct ChatComposer: View { + @Binding var draft: String + @Binding var mode: ChatMode + var canSend: Bool + var focus: FocusState.Binding + var placeholder: String + var quickActions: [QuickAction] + var accent: Color + var accent2: Color + var isBusy: Bool + @Binding var selectedDept: Department? + var onSend: () -> Void + var onQuickAction: (String) -> Void + + @Environment(\.uiLanguage) private var lang + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + TextField(placeholder, text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .font(CodepetTheme.inter(15)) + .lineLimit(1...6) + .focused(focus) + .onSubmit(onSend) + + deptChips + + HStack(spacing: 8) { + quickActionsMenu + modeMenu + Spacer() + sendButton + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(CodepetTheme.surface) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(accent.opacity(focus.wrappedValue ? 0.9 : 0.5), + lineWidth: focus.wrappedValue ? 1.5 : 1.2) + ) + .codepetShadow(CodepetTheme.floatingShadow) + .shadow(color: (focus.wrappedValue && !reduceTransparency) ? accent.opacity(0.28) : .clear, radius: 18) + .opacity(isBusy ? 0.72 : 1.0) + } + + private var deptChips: some View { + HStack(spacing: 6) { + ForEach(DepartmentCatalog.all.prefix(3)) { dep in + chip(dep) + } + Menu { + ForEach(DepartmentCatalog.all.dropFirst(3)) { dep in + Button { + selectedDept = (selectedDept?.key == dep.key) ? nil : dep + } label: { + Label(dep.name, systemImage: selectedDept?.key == dep.key ? "checkmark" : "") + } + } + } label: { + Text("•••").font(CodepetTheme.inter(12, weight: .semibold)) + .foregroundColor(CodepetTheme.mutedText) + .padding(.horizontal, 10).frame(height: 26) + .overlay(Capsule().stroke(CodepetTheme.hairline)) + } + .menuStyle(.button).buttonStyle(.plain).menuIndicator(.hidden).fixedSize() + } + } + + private func chip(_ dep: Department) -> some View { + let on = selectedDept?.key == dep.key + return Button { + selectedDept = on ? nil : dep + } label: { + Text(dep.name).font(CodepetTheme.inter(12, weight: .semibold)) + .foregroundColor(on ? dep.accent : CodepetTheme.bodyText) + .padding(.horizontal, 10).frame(height: 26) + .background(Capsule().fill(on ? dep.accent.opacity(0.15) : Color.clear)) + .overlay(Capsule().stroke(on ? dep.accent : CodepetTheme.hairline)) + }.buttonStyle(.plain) + } + + private var quickActionsMenu: some View { + Menu { + ForEach(quickActions) { qa in + Button { + onQuickAction(qa.title) + } label: { + Label(qa.title, systemImage: qa.systemImage) + } + } + } label: { + Image(systemName: "plus") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(CodepetTheme.bodyText) + .frame(width: 30, height: 30) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(CodepetTheme.hairline) + ) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + } + + private var modeMenu: some View { + Menu { + ForEach(ChatMode.allCases) { m in + Button(m.label(lang)) { mode = m } + } + } label: { + HStack(spacing: 6) { + Text(mode.label(lang)).font(CodepetTheme.inter(13)) + Image(systemName: "chevron.down") + .font(.system(size: 9)) + .foregroundColor(CodepetTheme.mutedText) + } + .foregroundColor(CodepetTheme.bodyText) + .padding(.horizontal, 12) + .frame(height: 30) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(CodepetTheme.hairline) + ) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + } + + private var sendButton: some View { + Button(action: onSend) { + Image(systemName: "arrow.up") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(canSend ? CodepetTheme.onAccent(accent) : .white) + .frame(width: 34, height: 34) + .background( + Circle().fill( + canSend + ? AnyShapeStyle(LinearGradient( + gradient: Gradient(colors: [accent, accent2]), + startPoint: .topLeading, endPoint: .bottomTrailing)) + : AnyShapeStyle(CodepetTheme.mutedText) + ) + .shadow(color: canSend ? accent.opacity(0.55) : .clear, radius: 10) + ) + } + .buttonStyle(.plain) + .disabled(!canSend) + } +} + +#if DEBUG +private struct ChatComposerPreviewHost: View { + @State private var draft = "" + @State private var mode: ChatMode = .ask + @FocusState private var focused: Bool + @State private var dept: Department? = nil + var body: some View { + ChatComposer( + draft: $draft, mode: $mode, canSend: !draft.isEmpty, + focus: $focused, + placeholder: "Ask anything about your company…", + quickActions: [ + QuickAction(title: "Run a task", systemImage: "checklist", + detail: "Ship a real deliverable from your roadmap."), + QuickAction(title: "Review the roadmap", systemImage: "map", + detail: "See what's next and what's blocking launch."), + ], + accent: CodepetTheme.accentPurple, accent2: CodepetTheme.accentPink, + isBusy: false, selectedDept: $dept, + onSend: {}, onQuickAction: { _ in } + ) + .frame(width: 640) + .padding() + } +} + +#Preview("ChatComposer") { ChatComposerPreviewHost() } +#endif diff --git a/codepet/Views/Copilot/ChatEmptyState.swift b/codepet/Views/Copilot/ChatEmptyState.swift new file mode 100644 index 0000000..6455b1d --- /dev/null +++ b/codepet/Views/Copilot/ChatEmptyState.swift @@ -0,0 +1,135 @@ +import SwiftUI + +/// The chat empty state: a centered, personalized hero greeting (time-of-day + +/// founder name, purple-gradient second line), the composer (injected so the +/// parent keeps ownership of draft/mode), and up to 3 LIVE roadmap cards +/// (beacon / needs-you / awaiting-approval) — or 3 prompt-starter cards that +/// fill the composer when there's no roadmap yet. Replaces the old +/// company-only greeting + static `QuickAction` pill row. The ambient purple +/// wash now lives in `ChatBackdrop` (applied by `CopilotChatView` behind both +/// the empty and active states), not here. +struct ChatEmptyState: View { + let state: ChatLandingState + let onOpenRoadmap: () -> Void + let onStarter: (String) -> Void + /// Matches the active chat's column width so the composer doesn't jump on first send. + var columnWidth: CGFloat = 760 + @ViewBuilder var composer: Composer + + @Environment(\.uiLanguage) private var lang + + var body: some View { + VStack(spacing: 28) { + CompanionOrb(size: 78) + + greeting + .padding(.horizontal, 24) + + composer + .frame(maxWidth: columnWidth) + + cards + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 40) + } + + /// Line 1: time-of-day + founder name, plain primary text. Line 2: the + /// company question, in a purple→pink gradient. Both centered/semibold. + private var greeting: some View { + VStack(spacing: 4) { + Text(state.greeting) + .font(CodepetTheme.inter(31, weight: .semibold)) + .foregroundColor(CodepetTheme.primaryText) + Text(state.question) + .font(CodepetTheme.inter(31, weight: .semibold)) + .foregroundStyle(LinearGradient( + gradient: Gradient(colors: [ + CodepetTheme.primaryText, CodepetTheme.accentPurple, CodepetTheme.accentPink]), + startPoint: .leading, endPoint: .trailing)) + } + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + /// Up to 3 live roadmap cards (beacon / needs-you / awaiting-approval), + /// omitted when zero/nil, tapping through to the Roadmap — or 3 + /// composer-filling prompt starters when there's no roadmap yet. + private var cards: some View { + Group { + if state.isEmpty { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + ForEach(starters, id: \.self) { starterCard($0) } + } + } else { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + if let b = state.beacon { + landingCard(eyebrow: lang == .vi ? "TIẾP THEO" : "DO THIS NEXT", + value: b.title, hue: CodepetTheme.accentPurple, onTap: onOpenRoadmap) + } + if state.needsYouCount > 0 { + landingCard(eyebrow: lang == .vi ? "CẦN BẠN" : "NEEDS YOU", + value: "\(state.needsYouCount)", hue: CodepetTheme.accentBlue, onTap: onOpenRoadmap) + } + if state.awaitingApprovalCount > 0 { + landingCard(eyebrow: lang == .vi ? "CHỜ DUYỆT" : "AWAITING APPROVAL", + value: "\(state.awaitingApprovalCount)", hue: CodepetTheme.accentGold, onTap: onOpenRoadmap) + } + } + } + } + .frame(maxWidth: columnWidth) + } + + private var starters: [String] { + lang == .vi + ? ["Soạn định vị của tôi", "Lên kế hoạch tuần này", "Xem lại bản tóm tắt"] + : ["Draft my positioning", "Plan this week", "Review my brief"] + } + + private func landingCard(eyebrow: String, value: String, hue: Color, onTap: @escaping () -> Void) -> some View { + Button(action: onTap) { + HStack(alignment: .top, spacing: 0) { + Capsule().fill(hue).frame(width: 3).padding(.vertical, 2) + VStack(alignment: .leading, spacing: 4) { + Text(eyebrow).font(CodepetTheme.inter(10, weight: .semibold)) + .foregroundColor(hue).tracking(0.5) + Text(value).font(CodepetTheme.inter(14, weight: .semibold)) + .foregroundColor(CodepetTheme.primaryText).lineLimit(2) + .multilineTextAlignment(.leading).fixedSize(horizontal: false, vertical: true) + }.padding(.leading, 12) + Spacer(minLength: 0) + } + .padding(.vertical, 12).padding(.trailing, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(CodepetTheme.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(CodepetTheme.hairline)) + }.buttonStyle(.plain) + } + + private func starterCard(_ text: String) -> some View { + Button { onStarter(text) } label: { + HStack(spacing: 8) { + Image(systemName: "sparkles").font(.system(size: 13)).foregroundColor(CodepetTheme.accentPurple) + Text(text).font(CodepetTheme.inter(13, weight: .semibold)) + .foregroundColor(CodepetTheme.primaryText) + .lineLimit(2).multilineTextAlignment(.leading).fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(.vertical, 12).padding(.horizontal, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(CodepetTheme.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(CodepetTheme.hairline)) + }.buttonStyle(.plain) + } +} + +#if DEBUG +#Preview("ChatEmptyState") { + ChatEmptyState( + state: ChatLandingState(company: .empty, now: Date(), language: .en), + onOpenRoadmap: {}, onStarter: { _ in } + ) { RoundedRectangle(cornerRadius: 16).fill(CodepetTheme.surface).frame(height: 96).frame(maxWidth: 600) } + .frame(width: 900, height: 700).background(Color.black).environmentObject(CompanyStore()) +} +#endif diff --git a/codepet/Views/Copilot/ChatExecLog.swift b/codepet/Views/Copilot/ChatExecLog.swift new file mode 100644 index 0000000..19c9aa8 --- /dev/null +++ b/codepet/Views/Copilot/ChatExecLog.swift @@ -0,0 +1,100 @@ +import SwiftUI + +/// The run "execute-log" — how the agent is working, styled like the web: the +/// specialist's pet avatar + name, then a titled card ("" / "DEPT") with a +/// " is doing the work…" header + an honest "N steps" counter, over a live +/// checklist. Done steps get a teal check, the current step spins, pending sit dim. +struct ExecLogRow: View { + let taskTitle: String + let deptName: String? + let steps: [ExecStep] + /// The specialist working (department handoff); nil → the global/host companion. + var companionId: String? = nil + + @EnvironmentObject private var companyStore: CompanyStore + + private var resolvedId: String { companionId ?? companyStore.company.companionId } + private var persona: PetCharacter? { PetCharacter.all[resolvedId] } + private var name: String { persona?.name ?? "Codepet" } + private var accent: Color { persona?.color ?? CodepetTheme.accentPurple } + private var currentIndex: Int? { steps.firstIndex { !$0.done } } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + CompanionAvatar(companionId: companionId, size: 28, isWorking: true) + VStack(alignment: .leading, spacing: 6) { + Text(name) + .font(CodepetTheme.inter(13, weight: .semibold)) + .foregroundColor(accent) + card + } + Spacer(minLength: 24) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var card: some View { + MessageCard(hue: accent) { + VStack(alignment: .leading, spacing: 8) { + // Title + department kicker (matches the web's "" / "DEPT"). + VStack(alignment: .leading, spacing: 2) { + Text(taskTitle) + .font(CodepetTheme.inter(15, weight: .semibold)) + .foregroundColor(CodepetTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + Text((deptName ?? "Codepet").uppercased()) + .font(CodepetTheme.inter(10, weight: .semibold)) + .tracking(0.5) + .foregroundColor(CodepetTheme.mutedText) + } + Divider().overlay(CodepetTheme.hairline) + // " is doing the work…" + honest step counter. + HStack(spacing: 8) { + Text("\(name) is doing the work…") + .font(CodepetTheme.inter(13, weight: .medium)) + .foregroundColor(CodepetTheme.bodyText) + Spacer(minLength: 8) + Text("\(steps.count) steps") + .font(CodepetTheme.inter(11)) + .foregroundColor(CodepetTheme.mutedText) + } + ForEach(Array(steps.enumerated()), id: \.element.id) { idx, step in + HStack(spacing: 8) { + icon(done: step.done, isCurrent: idx == currentIndex) + .frame(width: 15, height: 15) + Text(step.label) + .font(CodepetTheme.inter(13)) + .foregroundColor(step.done ? CodepetTheme.bodyText + : (idx == currentIndex ? CodepetTheme.primaryText : CodepetTheme.mutedText)) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + + @ViewBuilder private func icon(done: Bool, isCurrent: Bool) -> some View { + if done { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 13)).foregroundColor(CodepetTheme.accentTeal) + } else if isCurrent { + ProgressView().controlSize(.small).scaleEffect(0.7) + } else { + Image(systemName: "circle") + .font(.system(size: 12)).foregroundColor(CodepetTheme.mutedText.opacity(0.45)) + } + } +} + +#if DEBUG +#Preview("ExecLogRow") { + ExecLogRow(taskTitle: "Draft your positioning statement", deptName: "Marketing", steps: [ + ExecStep(label: "Reading your brief — mission, audience, your voice", done: true), + ExecStep(label: "Pulling in the Marketing playbook", done: true), + ExecStep(label: "Drafting Draft your positioning statement", done: false), + ExecStep(label: "Matching your tone and past decisions", done: false), + ], companionId: "nova") + .padding(40) + .environmentObject(CompanyStore()) +} +#endif diff --git a/codepet/Views/Copilot/ChatThinkingRow.swift b/codepet/Views/Copilot/ChatThinkingRow.swift new file mode 100644 index 0000000..caf7a03 --- /dev/null +++ b/codepet/Views/Copilot/ChatThinkingRow.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// The streaming/producing state: a breathing companion orb + a label that names +/// the work (via ChatThinkingLabel), with a subtle light sweep through the text. +/// Replaces the old static typingRow/producingRow. Reduce Motion → orb static + +/// no sweep. +struct ChatThinkingRow: View { + /// A real in-flight title, or nil for a plain chat turn (→ "Working on it…"). + var taskTitle: String? = nil + /// The specialist working (a department handoff); nil → the global companion. + var companionId: String? = nil + + @Environment(\.uiLanguage) private var lang + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var label: String { ChatThinkingLabel.text(taskTitle: taskTitle, language: lang) } + + var body: some View { + HStack(spacing: 10) { + CompanionAvatar(companionId: companionId, size: 28, isWorking: true) + shimmerLabel + Spacer(minLength: 24) + } + } + + @ViewBuilder private var shimmerLabel: some View { + let base = Text(label).font(CodepetTheme.inter(13)).foregroundColor(CodepetTheme.mutedText) + if reduceMotion { + base + } else { + TimelineView(.animation) { tl in + let phase = tl.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.1) / 2.1 + base.overlay( + LinearGradient( + gradient: Gradient(colors: [.clear, CodepetTheme.primaryText.opacity(0.7), .clear]), + startPoint: .leading, endPoint: .trailing) + .frame(width: 60) + .offset(x: CGFloat(phase * 260 - 60)) + .mask(base) + .allowsHitTesting(false) + ) + } + } + } +} + +#if DEBUG +#Preview("ChatThinkingRow") { + VStack(alignment: .leading, spacing: 16) { + ChatThinkingRow(taskTitle: nil) + ChatThinkingRow(taskTitle: "positioning brief") + } + .padding(40) + .environmentObject(CompanyStore()) +} +#endif diff --git a/codepet/Views/Copilot/CompanionOrb.swift b/codepet/Views/Copilot/CompanionOrb.swift new file mode 100644 index 0000000..2e16616 --- /dev/null +++ b/codepet/Views/Copilot/CompanionOrb.swift @@ -0,0 +1,127 @@ +import SwiftUI + +/// The chat avatar for a message. A department SPECIALIST (non-nil `companionId`) +/// shows its pet character sprite — the pet visibly "appears"; the HOST (nil) +/// keeps the abstract orb. Used by message bubbles, the thinking row, and the +/// execute-log so the identity rule lives in one place. +struct CompanionAvatar: View { + var companionId: String? = nil + var size: CGFloat = 28 + var isWorking: Bool = false + + var body: some View { + if let id = companionId { + CharacterImage(id, size: size) + } else { + CompanionOrb(size: size, glow: false, isWorking: isWorking) + } + } +} + +/// A luminous, companion-tinted sphere — the companion's identity in chat +/// (hero focal, message avatar, thinking indicator). Reads the active companion's +/// two hues from the store so switching companion re-tints every orb. Pure +/// SwiftUI, no assets. Only `isWorking` changes scale (a slow breathe); at rest +/// the internal colour drifts. Reduce Motion → one static frame. +struct CompanionOrb: View { + var size: CGFloat = 78 + var glow: Bool = true + var isWorking: Bool = false + /// Override the companion this orb renders (a per-message department + /// specialist). nil → the account's active/global companion. + var companionId: String? = nil + + @EnvironmentObject var companyStore: CompanyStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + private var character: PetCharacter? { + PetCharacter.all[companionId ?? companyStore.company.companionId] + } + private var hue1: Color { character?.color ?? CodepetTheme.accentPurple } + private var hue2: Color { character?.secondColor ?? CodepetTheme.accentPink } + + var body: some View { + Group { + if reduceMotion { + orb(t: 0) + } else { + TimelineView(.animation) { tl in + orb(t: tl.date.timeIntervalSinceReferenceDate) + } + } + } + .frame(width: size, height: size) + } + + private func breathe(_ t: Double) -> CGFloat { + guard isWorking && !reduceMotion else { return 1.0 } + // 3.6s period, scale 1.0 … 1.07 + return 1.0 + 0.035 * (1 + CGFloat(sin(t * (2 * .pi / 3.6)))) + } + + private func orb(t: Double) -> some View { + ZStack { + // 1. near-black luminous core — makes colour read as emitted light + Circle().fill(RadialGradient( + gradient: Gradient(colors: [CodepetTheme.chatOrbCore, .black]), + center: .center, startRadius: 0, endRadius: size * 0.5)) + + // 2. three internal colour bands, drifting on different periods, additive + band(hue1, degPerSec: 30, t: t) + band(hue2, degPerSec: 42, t: t) + band(.white.opacity(0.5), degPerSec: 22, t: t) // hue1 "lifted toward white" + + // 3. specular crescent + Circle().fill(RadialGradient( + gradient: Gradient(colors: [.white.opacity(0.9), .clear]), + center: UnitPoint(x: 0.34 + 0.015 * sin(t * 0.6), y: 0.30), + startRadius: 0, endRadius: size * 0.30)) + + // 4. base shading for sphericality + Circle().fill(RadialGradient( + gradient: Gradient(colors: [.clear, .black.opacity(0.40)]), + center: UnitPoint(x: 0.70, y: 0.80), + startRadius: size * 0.15, endRadius: size * 0.60)) + + // 5. rim, brightest near the specular + Circle().strokeBorder( + AngularGradient(gradient: Gradient(colors: [ + .white.opacity(0.55), .clear, .clear, .white.opacity(0.2), .white.opacity(0.55)]), + center: .center), + lineWidth: max(1, size * 0.02)) + } + .compositingGroup() + .clipShape(Circle()) + .scaleEffect(breathe(t)) + .background(bloom) // 6. outer bloom, behind and unclipped + } + + private func band(_ color: Color, degPerSec: Double, t: Double) -> some View { + AngularGradient( + gradient: Gradient(colors: [color.opacity(0.0), color.opacity(0.6), color.opacity(0.0)]), + center: .center, + angle: .degrees(reduceMotion ? 0 : t * degPerSec)) + .clipShape(Circle()) + .blendMode(.plusLighter) + } + + private var bloom: some View { + Circle() + .fill(hue1.opacity((glow && !reduceTransparency) ? 0.45 : 0.0)) + .blur(radius: size * 0.45) + .scaleEffect(1.12) + } +} + +#if DEBUG +#Preview("CompanionOrb") { + HStack(spacing: 24) { + CompanionOrb(size: 78, isWorking: true) + CompanionOrb(size: 28, glow: false) + } + .padding(40) + .background(Color.black) + .environmentObject(CompanyStore()) +} +#endif diff --git a/codepet/Views/Copilot/CopilotChatView.swift b/codepet/Views/Copilot/CopilotChatView.swift index 95491f7..5f13f79 100644 --- a/codepet/Views/Copilot/CopilotChatView.swift +++ b/codepet/Views/Copilot/CopilotChatView.swift @@ -1,300 +1,329 @@ // codepet/Views/Copilot/CopilotChatView.swift import SwiftUI +import AppKit /// The Copilot column: a company-grounded chat with the founder's companion. struct CopilotChatView: View { + /// Whether the shell's sidebar is collapsed — the header insets to clear the + /// floating collapse toggle only then; flush-left when the sidebar is open. + var sidebarCollapsed: Bool = false + @EnvironmentObject var companyStore: CompanyStore @Environment(\.uiLanguage) private var lang + @State private var mode: ChatMode = .ask + @State private var selectedDept: Department? @FocusState private var inputFocused: Bool - /// Toggles the "History" thread switcher over the message list. Session-only - /// UI state — the History stub (see the header) now activates this. - @State private var showHistory = false - private var companionName: String { - PetCharacter.all[companyStore.company.companionId]?.name ?? "Codepet" - } - private var companyName: String { - let n = (companyStore.company.brief.projectName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return n.isEmpty ? "Codepet" : n - } - private var founderName: String { - let n = (companyStore.company.brief.founderName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return n.isEmpty ? (lang == .vi ? "bạn" : "there") : n + /// Max width of the conversation column + composer — matches Claude Code's + /// comfortable centered reading width (both stay in sync via this one value). + private let chatColumnWidth: CGFloat = 760 + /// Minimum side gutter kept at ALL window sizes so the column shrinks to fit a + /// narrow window (never edge-to-edge) and the message list + composer share the + /// exact same left/right edges — the Claude-style responsive behavior. + private let chatGutter: CGFloat = 24 + + // Thread header (name dropdown + Share). + @State private var renamingThread = false + @State private var threadRenameDraft = "" + @State private var shareCopied = false + + private var activeThreadTitle: String { + companyStore.threads.first { $0.id == companyStore.activeThreadId }?.title + ?? (lang == .vi ? "Đoạn chat mới" : "New chat") } - private var canSend: Bool { - !companyStore.chatDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && !companyStore.isCompanionTyping && !companyStore.isStreaming + /// Recent threads for the header switcher, newest-first (active one excluded — it's the label). + private var recentThreads: [ChatThread] { + sortThreadsByRecent(companyStore.threads) + .filter { $0.id != companyStore.activeThreadId } + .prefix(8).map { $0 } } - /// True while a chat turn is in flight — gates the History toggle here and - /// (via `ThreadListView`'s own copy of this) the "New chat"/switch/delete - /// row controls, so the UI can't trigger a mid-stream thread repoint even - /// though `CompanyStore` also guards it at the source. Mirrors `canSend`'s - /// existing streaming gate. - private var isChatBusy: Bool { - companyStore.isCompanionTyping || companyStore.isStreaming + private var isChatBusy: Bool { companyStore.isCompanionTyping || companyStore.isStreaming || companyStore.isFanningOut } + + /// A slim top bar: the active thread's name as a dropdown switcher (New chat + + /// recent threads + Rename), plus Share (copies the transcript). Leading inset + /// clears the shell's sidebar-collapse toggle when the sidebar is hidden. + private var chatHeader: some View { + HStack(spacing: 8) { + threadMenu + Spacer(minLength: 8) + shareButton + } + .padding(.leading, sidebarCollapsed ? 44 : 16) + .padding(.trailing, 16) + .padding(.vertical, 8) + .alert(lang == .vi ? "Đổi tên đoạn chat" : "Rename chat", isPresented: $renamingThread) { + TextField(lang == .vi ? "Tên" : "Name", text: $threadRenameDraft) + Button(lang == .vi ? "Lưu" : "Save") { + if let id = companyStore.activeThreadId { companyStore.renameThread(id, title: threadRenameDraft) } + } + Button(lang == .vi ? "Hủy" : "Cancel", role: .cancel) {} + } } - var body: some View { - VStack(spacing: 0) { - header - Divider() - if showHistory { - ThreadListView(showHistory: $showHistory) - } else { - messageList - letsBuild + private var threadMenu: some View { + Menu { + Button { companyStore.newChat() } label: { + Label(lang == .vi ? "Đoạn chat mới" : "New chat", systemImage: "square.and.pencil") + }.disabled(isChatBusy) + if !recentThreads.isEmpty { + Section(lang == .vi ? "Gần đây" : "Recent") { + ForEach(recentThreads) { t in + Button { + companyStore.switchThread(t.id) + } label: { + Text(t.title ?? (lang == .vi ? "Đoạn chat mới" : "New chat")) + }.disabled(isChatBusy) + } + } } Divider() - inputBar + Button { + threadRenameDraft = activeThreadTitle + renamingThread = true + } label: { + Label(lang == .vi ? "Đổi tên đoạn này" : "Rename this chat", systemImage: "pencil") + }.disabled(companyStore.activeThreadId == nil) + } label: { + HStack(spacing: 5) { + Text(activeThreadTitle) + .font(CodepetTheme.inter(14, weight: .semibold)) + .foregroundColor(CodepetTheme.primaryText) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(CodepetTheme.mutedText) + } } - .frame(maxHeight: .infinity) + .menuStyle(.button).buttonStyle(.plain).menuIndicator(.hidden) + .fixedSize() } - // Web Copilot header: "Your team" + "guiding · {company}" + History toggle - // (was an inert stub — now opens/closes the thread switcher below). - private var header: some View { - HStack { - VStack(alignment: .leading, spacing: 1) { - Text(lang == .vi ? "Đội của bạn" : "Your team") - .font(CodepetTheme.inter(14, weight: .semibold)).foregroundColor(CodepetTheme.primaryText) - Text((lang == .vi ? "đang hỗ trợ · " : "guiding · ") + companyName) - .font(CodepetTheme.inter(11)).foregroundColor(CodepetTheme.mutedText).lineLimit(1) + private var shareButton: some View { + Button { copyTranscript() } label: { + HStack(spacing: 5) { + Image(systemName: shareCopied ? "checkmark" : "square.and.arrow.up") + .font(.system(size: 12, weight: .medium)) + Text(shareCopied ? (lang == .vi ? "Đã sao chép" : "Copied") + : (lang == .vi ? "Chia sẻ" : "Share")) + .font(CodepetTheme.inter(12, weight: .medium)) } - Spacer() - Button { showHistory.toggle() } label: { - Text(lang == .vi ? "Lịch sử" : "History") - .font(CodepetTheme.inter(11, weight: .medium)) - .foregroundColor(isChatBusy ? CodepetTheme.mutedText.opacity(0.5) - : (showHistory ? CodepetTheme.accentPurple : CodepetTheme.mutedText)) - } - .buttonStyle(.plain) - .disabled(isChatBusy) + .foregroundColor(shareCopied ? CodepetTheme.accentTeal : CodepetTheme.mutedText) } - .padding(.horizontal, 12).padding(.vertical, 10) + .buttonStyle(.plain) + } + + /// Copy the active thread's transcript (plain text) to the clipboard. No + /// share-link backend yet — an honest local copy the founder can paste anywhere. + private func copyTranscript() { + let text = companyStore.chatMessages.compactMap { m -> String? in + let body = m.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !body.isEmpty else { return nil } + let who = m.role == .me ? (lang == .vi ? "Bạn" : "You") : companionName + return "\(who): \(body)" + }.joined(separator: "\n\n") + guard !text.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + shareCopied = true + Task { try? await Task.sleep(nanoseconds: 1_600_000_000); shareCopied = false } + } + + private var companionName: String { + PetCharacter.all[companyStore.company.companionId]?.name ?? "Codepet" + } + private var companionAccent: Color { + PetCharacter.all[companyStore.company.companionId]?.color ?? CodepetTheme.accentPurple + } + private var companionAccent2: Color { + PetCharacter.all[companyStore.company.companionId]?.secondColor ?? CodepetTheme.accentPink + } + private var canSend: Bool { + !companyStore.chatDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !companyStore.isCompanionTyping && !companyStore.isStreaming && !companyStore.isFanningOut } - // "Let's build" CTA — stub (the live build session is a later effort). - private var letsBuild: some View { - Button { } label: { - Text("🔨 " + (lang == .vi ? "Cùng xây" : "Let's build")) - .font(CodepetTheme.inter(12, weight: .semibold)).foregroundColor(CodepetTheme.accentPurple) - .frame(maxWidth: .infinity).padding(.vertical, 8) - .background(CodepetTheme.accentPurple.opacity(0.08)) - }.buttonStyle(.plain) + var body: some View { + ZStack { + ChatBackdrop() + VStack(spacing: 0) { + if companyStore.chatMessages.isEmpty { + ChatEmptyState( + state: ChatLandingState(company: companyStore.company, now: Date(), language: lang), + onOpenRoadmap: { companyStore.selectedDeptKey = nil; companyStore.select(.roadmap) }, + onStarter: { companyStore.chatDraft = $0; inputFocused = true }, + columnWidth: chatColumnWidth + ) { + composerView + } + } else { + chatHeader + messageList + composerView + .frame(maxWidth: chatColumnWidth) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, chatGutter) + .padding(.vertical, 10) + } + } + .frame(maxHeight: .infinity) + } } private var messageList: some View { ScrollViewReader { proxy in ScrollView { - VStack(alignment: .leading, spacing: 10) { - if companyStore.chatMessages.isEmpty { greeting } + VStack(alignment: .leading, spacing: 24) { ForEach(companyStore.chatMessages) { m in CopilotBubble(message: m).id(m.id) } - if companyStore.isCompanionTyping { typingRow.id("typing") } + if companyStore.isCompanionTyping { ChatThinkingRow(taskTitle: nil).id("typing") } + if !companyStore.activeAgentRuns.isEmpty { + AgentsWorkingRow(runs: companyStore.activeAgentRuns).id("agents") + } } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 40) + .padding(.bottom, 24) + .frame(maxWidth: chatColumnWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, chatGutter) } + // A transcript that arrives already-populated — a resumed thread on + // launch, or a switch into an existing one — never fires the count + // change below, so it would open scrolled to its OLDEST message. + // Jump to the newest without animation (no scroll-through-history + // on launch); deferred one runloop turn so the list has laid out. + .onAppear { scrollToLatest(proxy) } + .onChange(of: companyStore.activeThreadId) { _, _ in scrollToLatest(proxy) } .onChange(of: companyStore.chatMessages.count) { _, _ in withAnimation { proxy.scrollTo(companyStore.chatMessages.last?.id, anchor: .bottom) } } .onChange(of: companyStore.isCompanionTyping) { _, typing in if typing { withAnimation { proxy.scrollTo("typing", anchor: .bottom) } } } + .onChange(of: companyStore.activeAgentRuns.count) { _, count in + if count > 0 { withAnimation { proxy.scrollTo("agents", anchor: .bottom) } } + } } } - private var greeting: some View { - VStack(alignment: .leading, spacing: 10) { - Text(lang == .vi - ? "Chào \(founderName). Hỏi mình bất cứ điều gì về \(companyName) — nên tập trung vào đâu, điều gì đang cản trở, hay xây gì tiếp theo." - : "Welcome, \(founderName). Ask me anything about \(companyName) — where to focus, what's blocking you, or what to build next.") - .font(CodepetTheme.inter(13)).foregroundColor(CodepetTheme.bodyText) - .fixedSize(horizontal: false, vertical: true) - VStack(alignment: .leading, spacing: 6) { - ForEach(quickStarts, id: \.self) { chip in - Button { Task { await companyStore.sendChat(chip, language: lang) } } label: { - Text(chip).font(CodepetTheme.inter(12)).foregroundColor(CodepetTheme.accentPurple) - .padding(.horizontal, 10).padding(.vertical, 6) - .background(Capsule().fill(CodepetTheme.accentPurple.opacity(0.1))) - }.buttonStyle(.plain) - } - } + /// Pin the list to its last message with no animation. Deferred to the next + /// runloop turn because `onAppear` fires before the rows have laid out, and + /// `scrollTo` against an unlaid-out list is a no-op. + /// The target is read INSIDE the deferred block, not captured before it: a + /// thread switch sets `activeThreadId` and repoints `chatMessages` as two + /// mutations, so reading eagerly can capture the outgoing thread's last id + /// and scroll to a row that no longer exists (a silent no-op). + private func scrollToLatest(_ proxy: ScrollViewProxy) { + DispatchQueue.main.async { + guard let last = companyStore.chatMessages.last?.id else { return } + proxy.scrollTo(last, anchor: .bottom) } } - private var quickStarts: [String] { - lang == .vi - ? ["Nên tập trung vào đâu trước?", "Tóm tắt tình hình công ty", "Điều gì đang cản trở ra mắt?"] - : ["What should I focus on first?", "Summarize where my company is", "What\u{2019}s blocking my launch?"] + /// The one composer instance, reused in the empty hero and docked in an + /// active conversation. State (draft/mode/focus) stays here so both + /// placements share the same value. + private var composerView: some View { + ChatComposer( + draft: $companyStore.chatDraft, + mode: $mode, + canSend: canSend, + focus: $inputFocused, + placeholder: placeholder, + quickActions: quickActions, + accent: companionAccent, + accent2: companionAccent2, + isBusy: companyStore.isCompanionTyping || companyStore.isStreaming || companyStore.isFanningOut, + selectedDept: $selectedDept, + onSend: send, + onQuickAction: runQuickAction + ) } - private var typingRow: some View { - Text(lang == .vi ? "\(companionName) đang trả lời…" : "\(companionName) is typing…") - .font(.pixelSystem(size: 11)) - .foregroundColor(CodepetTheme.mutedText) + private var placeholder: String { + lang == .vi + ? "Hỏi \(companionName) bất cứ điều gì về công ty…" + : "Ask \(companionName) anything about your company…" } - private var inputBar: some View { - HStack(spacing: 8) { - TextField(lang == .vi ? "Hỏi \(companionName) bất cứ điều gì về công ty…" : "Ask \(companionName) anything about your company…", - text: $companyStore.chatDraft, axis: .vertical) - .textFieldStyle(.plain) - .font(CodepetTheme.inter(12)) - .lineLimit(1...4) - .focused($inputFocused) - .onSubmit(send) - Button(action: send) { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 22)) - .foregroundColor(canSend ? CodepetTheme.accentPurple : CodepetTheme.mutedText) - } - .buttonStyle(.plain) - .disabled(!canSend) + /// The stable label for the parallel fan-out chip — compared in runQuickAction to + /// route the tap to fanOutNextMoves instead of the normal chat-send path. + private var fanOutTitle: String { lang == .vi ? "Chạy các bước tiếp theo" : "Run my next moves" } + + /// Capability quick-actions — the titles are complete intents, so they are + /// sent as-is (NOT mode-shaped). Replaces the old `quickStarts`. Each also + /// carries a short localized "why" detail shown as helper text on its card. + private var quickActions: [QuickAction] { + let en = ["Run a task", "Review the roadmap", "Set up a department", "Summarize where we are"] + let vi = ["Chạy một tác vụ", "Xem lộ trình", "Thiết lập một phòng ban", "Tóm tắt tình hình công ty"] + let detailsEn = [ + "Ship a real deliverable from your roadmap.", + "See what's next and what's blocking launch.", + "Bring Marketing, Legal, or Finance online.", + "A quick read on the whole company.", + ] + let detailsVi = [ + "Tạo một sản phẩm thực từ lộ trình.", + "Xem việc tiếp theo và điều đang cản trở.", + "Kích hoạt Marketing, Pháp lý hoặc Tài chính.", + "Tóm tắt nhanh toàn công ty.", + ] + let icons = ["checklist", "map", "square.grid.2x2", "doc.text"] + let titles = lang == .vi ? vi : en + let details = lang == .vi ? detailsVi : detailsEn + let base = (0.. Bool { + let t = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return t == "run my next moves" || t == "chạy các bước tiếp theo" } -} -/// The "History" panel: session-only multi-thread switcher — "+ New chat", one -/// row per thread (title/relative time, active row highlighted), rename + delete -/// per row. Tapping a row switches threads and closes the panel. Level 1: pure -/// `CompanyStore` state, no persistence. Native port of the web `ThreadList()`. -struct ThreadListView: View { - @EnvironmentObject var companyStore: CompanyStore - @Environment(\.uiLanguage) private var lang - @Binding var showHistory: Bool - @State private var renamingId: String? - @State private var renameDraft = "" - // Stamped once at appear (not read live in the body) so relative times don't - // recompute on every re-render — the panel remounts each time History opens, - // which is when the times should refresh. Mirrors the web's lazy `useState`. - @State private var now = Date() - - private var rows: [ChatThread] { sortThreadsByRecent(companyStore.threads) } - /// Gates "New chat" + per-row switch/delete while a turn is in flight — - /// mirrors `CopilotChatView.isChatBusy` (also gates the History toggle - /// that opens this panel). Rename is left enabled: it only edits a title - /// in `threads`, it never repoints `chatMessages`, so it can't corrupt an - /// in-flight stream. `CompanyStore.newChat()`/`switchThread(_:)`/ - /// `deleteThread(_:)` guard the same condition independently — this is UI - /// affordance on top of that store-level guard, not a substitute for it. - private var isChatBusy: Bool { - companyStore.isCompanionTyping || companyStore.isStreaming + private func runQuickAction(_ text: String) { + guard !companyStore.isCompanionTyping, !companyStore.isStreaming, !companyStore.isFanningOut else { return } + if isFanOutPhrase(text) { + Task { await companyStore.fanOutNextMoves(language: lang) } + return + } + Task { await companyStore.sendChat(text, language: lang, department: selectedDept) } } - var body: some View { - VStack(alignment: .leading, spacing: 0) { - Button { - companyStore.newChat() - showHistory = false - } label: { - Text("+ " + (lang == .vi ? "Đoạn chat mới" : "New chat")) - .font(CodepetTheme.inter(12, weight: .semibold)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(isChatBusy ? CodepetTheme.accentPurple.opacity(0.5) : CodepetTheme.accentPurple)) - } - .buttonStyle(.plain) - .disabled(isChatBusy) - .padding(12) - - if rows.isEmpty { - Text(lang == .vi ? "Chưa có đoạn chat nào." : "No chats yet.") - .font(CodepetTheme.inter(12)).foregroundColor(CodepetTheme.mutedText) - .padding(.horizontal, 12) - Spacer() - } else { - ScrollView { - VStack(spacing: 6) { - ForEach(rows) { thread in - threadRow(thread) - } - } - .padding(.horizontal, 12).padding(.bottom, 12) - } - } + private func send() { + guard canSend else { return } + // A pending enrichment question answers conversationally: the composer text + // IS the answer (raw, no mode-shaping), routed to answerInterview. + if let pending = companyStore.pendingInterview { + let answer = companyStore.chatDraft + companyStore.chatDraft = "" + Task { await companyStore.answerInterview(messageId: pending.id, gap: pending.gap, + answer: answer, language: lang) } + inputFocused = true + return } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .onAppear { now = Date() } - } - - private func threadRow(_ thread: ChatThread) -> some View { - let isActive = thread.id == companyStore.activeThreadId - return Group { - if renamingId == thread.id { - HStack(spacing: 6) { - TextField(lang == .vi ? "Đổi tên đoạn chat" : "Rename chat", text: $renameDraft) - .textFieldStyle(.plain) - .font(CodepetTheme.inter(12)) - .onSubmit { commitRename(thread.id) } - Button(lang == .vi ? "Lưu" : "Save") { commitRename(thread.id) } - .buttonStyle(.plain) - .font(CodepetTheme.inter(11, weight: .semibold)) - .foregroundColor(CodepetTheme.accentPurple) - } - .padding(10) - } else { - HStack(spacing: 6) { - Button { - companyStore.switchThread(thread.id) - showHistory = false - } label: { - VStack(alignment: .leading, spacing: 2) { - Text(thread.title ?? (lang == .vi ? "Đoạn chat mới" : "New chat")) - .font(CodepetTheme.inter(12, weight: isActive ? .semibold : .regular)) - .foregroundColor(CodepetTheme.primaryText) - .lineLimit(1) - Text(relativeTime(thread.updatedAt, now: now)) - .font(CodepetTheme.inter(10)) - .foregroundColor(CodepetTheme.mutedText) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - .disabled(isChatBusy) - - Menu { - Button { - renameDraft = thread.title ?? "" - renamingId = thread.id - } label: { - Label(lang == .vi ? "Đổi tên" : "Rename", systemImage: "pencil") - } - Button(role: .destructive) { - companyStore.deleteThread(thread.id) - } label: { - Label(lang == .vi ? "Xóa" : "Delete", systemImage: "trash") - } - .disabled(isChatBusy) - } label: { - Image(systemName: "ellipsis.circle") - .foregroundColor(CodepetTheme.mutedText) - } - .menuStyle(.borderlessButton) - .frame(width: 22) - } - .padding(10) - } + // Typing the fan-out phrase works like tapping the "Run my next moves" chip. + if isFanOutPhrase(companyStore.chatDraft) { + companyStore.chatDraft = "" + Task { await companyStore.fanOutNextMoves(language: lang) } + inputFocused = true + return } - .background(RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(isActive ? CodepetTheme.accentPurple.opacity(0.08) : CodepetTheme.surface)) - } - - private func commitRename(_ id: String) { - companyStore.renameThread(id, title: renameDraft) - renamingId = nil + let text = mode.shape(companyStore.chatDraft, language: lang) + companyStore.chatDraft = "" + Task { await companyStore.sendChat(text, language: lang, department: selectedDept) } + // Re-assert focus: the composer moves between the empty-state and docked + // `if/else` branches once `chatMessages` goes empty→non-empty, so SwiftUI + // rebuilds the TextField and drops focus after the first send. + inputFocused = true } } @@ -305,12 +334,15 @@ struct CopilotBubble: View { @EnvironmentObject var companyStore: CompanyStore @Environment(\.uiLanguage) private var lang @State private var showDetail = false - @State private var interviewDraft = "" + @State private var reaction: Bool? // nil = none, true = up, false = down private var isMe: Bool { message.role == .me } private var companionName: String { PetCharacter.all[companyStore.company.companionId]?.name ?? "Codepet" } + private var companionAccent: Color { + PetCharacter.all[companyStore.company.companionId]?.color ?? CodepetTheme.accentPurple + } var body: some View { if message.producing { @@ -330,23 +362,25 @@ struct CopilotBubble: View { } .frame(maxWidth: .infinity, alignment: .leading) } else if let gap = message.interview, !message.interviewAnswered { - interviewCard(gap) + interviewMessage(gap) } else { textBubble } } private func actionButton(_ action: FirstRunAction) -> some View { - Button { - Task { await companyStore.runFirstRunAction(messageId: message.id, language: lang) } - } label: { - Text((lang == .vi ? "Làm cùng mình: " : "Do it with me: ") + action.taskTitle) - .font(.pixelSystem(size: 11, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 12).padding(.vertical, 7) - .background(Capsule().fill(CodepetTheme.accentPurple)) + MessageCard(hue: MessageCardStyle.hue(for: .firstRunAction, companionAccent: companionAccent)) { + Button { + Task { await companyStore.runFirstRunAction(messageId: message.id, language: lang) } + } label: { + Text((lang == .vi ? "Làm cùng mình: " : "Do it with me: ") + action.taskTitle) + .font(CodepetTheme.inter(13, weight: .semibold)) + .foregroundColor(CodepetTheme.onAccent(CodepetTheme.accentPurple)) + .padding(.horizontal, 12).padding(.vertical, 7) + .background(Capsule().fill(CodepetTheme.accentPurple)) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } /// A tappable "go here" chip from byte's `nav` action — NOT auto-navigated @@ -356,14 +390,16 @@ struct CopilotBubble: View { private func navChip(_ nav: NavAction) -> some View { let label = AppView.from(navDestination: nav.destination)?.title(lang) ?? nav.destination return HStack { - Button { companyStore.activateNav(nav) } label: { - Text((lang == .vi ? "Đi tới " : "Go to ") + label) - .font(.pixelSystem(size: 11, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 12).padding(.vertical, 7) - .background(Capsule().fill(CodepetTheme.accentPurple)) + MessageCard(hue: MessageCardStyle.hue(for: .navChip, companionAccent: companionAccent)) { + Button { companyStore.activateNav(nav) } label: { + Text((lang == .vi ? "Đi tới " : "Go to ") + label) + .font(CodepetTheme.inter(12, weight: .semibold)) + .foregroundColor(CodepetTheme.onAccent(CodepetTheme.accentPurple)) + .padding(.horizontal, 12).padding(.vertical, 7) + .background(Capsule().fill(CodepetTheme.accentPurple)) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) Spacer(minLength: 24) } } @@ -378,27 +414,25 @@ struct CopilotBubble: View { let why = item?.why let verb = item?.category.enableVerb(lang) ?? (lang == .vi ? "Bật" : "Enable") return HStack { - CodepetCard { + MessageCard(hue: MessageCardStyle.hue(for: .setupSuggestion, companionAccent: companionAccent)) { VStack(alignment: .leading, spacing: 8) { Text(name) - .font(.pixelSystem(size: 12, weight: .semibold)) + .font(CodepetTheme.inter(14, weight: .semibold)) .foregroundColor(CodepetTheme.primaryText) if let why, !why.isEmpty { Text(why) - .font(.pixelSystem(size: 11)) + .font(CodepetTheme.inter(13)) .foregroundColor(CodepetTheme.mutedText) .fixedSize(horizontal: false, vertical: true) } Button { Task { await companyStore.activateSetup(setup) } } label: { Text(verb) - .font(.pixelSystem(size: 10, weight: .semibold)) - .foregroundColor(.white) + .font(CodepetTheme.inter(12, weight: .semibold)) + .foregroundColor(CodepetTheme.onAccent(CodepetTheme.accentPurple)) .padding(.horizontal, 12).padding(.vertical, 5) .background(Capsule().fill(CodepetTheme.accentPurple)) }.buttonStyle(.plain) } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) } Spacer(minLength: 24) } @@ -409,117 +443,190 @@ struct CopilotBubble: View { /// there is no tap/approval affordance here, just an acknowledgement. private func notedChip(_ facts: [RememberedFact]) -> some View { HStack { - VStack(alignment: .leading, spacing: 4) { - ForEach(facts, id: \.topic) { fact in - Text("📌 " + (lang == .vi ? "Đã ghi nhớ" : "Noted") + " · \(fact.topic) — \(fact.statement)") - .font(.pixelSystem(size: 10)) - .foregroundColor(CodepetTheme.mutedText) - .padding(.horizontal, 10).padding(.vertical, 5) - .background(Capsule().fill(CodepetTheme.surface)) + MessageCard(hue: MessageCardStyle.hue(for: .noted, companionAccent: companionAccent)) { + VStack(alignment: .leading, spacing: 4) { + ForEach(facts, id: \.topic) { fact in + Text("📌 " + (lang == .vi ? "Đã ghi nhớ" : "Noted") + " · \(fact.topic) — \(fact.statement)") + .font(CodepetTheme.inter(13)) + .foregroundColor(CodepetTheme.mutedText) + } } } Spacer(minLength: 24) } } - /// First-run enrichment interview: question + why-line + free-text answer, - /// Send (saves raw text to the brief) or Skip (advances without saving). - private func interviewCard(_ gap: InterviewGap) -> some View { + /// First-run enrichment question, rendered conversationally: byte asks it as a + /// normal companion message (orb + question + why-line), and the founder answers + /// in the MAIN composer (send() routes to answerInterview while this is pending). + /// A subtle Skip link advances without saving. No embedded form/box. + private func interviewMessage(_ gap: InterviewGap) -> some View { let q = EnrichInterview.question(for: gap, language: lang) - let canSend = !interviewDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - return HStack { - VStack(alignment: .leading, spacing: 8) { + return HStack(alignment: .top, spacing: 10) { + CompanionOrb(size: 28, glow: false) + VStack(alignment: .leading, spacing: 6) { Text(q.ask) - .font(.pixelSystem(size: 12, weight: .semibold)) + .font(CodepetTheme.inter(15)) .foregroundColor(CodepetTheme.primaryText) .fixedSize(horizontal: false, vertical: true) Text(q.why) - .font(.pixelSystem(size: 11)) + .font(CodepetTheme.inter(13)) .foregroundColor(CodepetTheme.mutedText) .fixedSize(horizontal: false, vertical: true) - TextField(lang == .vi ? "Nhập câu trả lời…" : "Type your answer…", - text: $interviewDraft, axis: .vertical) - .textFieldStyle(.plain) - .font(.pixelSystem(size: 12)) - .lineLimit(1...4) - .padding(.horizontal, 8).padding(.vertical, 6) - .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(CodepetTheme.surface)) - HStack(spacing: 8) { - Button { - let answer = interviewDraft - interviewDraft = "" - Task { await companyStore.answerInterview(messageId: message.id, gap: gap, answer: answer, language: lang) } - } label: { - Text(lang == .vi ? "Gửi" : "Send") - .font(.pixelSystem(size: 10, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 12).padding(.vertical, 5) - .background(Capsule().fill(canSend ? CodepetTheme.accentPurple : CodepetTheme.mutedText)) - } - .buttonStyle(.plain).disabled(!canSend) - Button { - interviewDraft = "" - Task { await companyStore.answerInterview(messageId: message.id, gap: gap, answer: nil, language: lang) } - } label: { - Text(lang == .vi ? "Bỏ qua" : "Skip") - .font(.pixelSystem(size: 10, weight: .semibold)) - .foregroundColor(CodepetTheme.mutedText) - .padding(.horizontal, 12).padding(.vertical, 5) - .background(Capsule().stroke(CodepetTheme.hairline)) - } - .buttonStyle(.plain) + Button { + Task { await companyStore.answerInterview(messageId: message.id, gap: gap, + answer: nil, language: lang) } + } label: { + Text(lang == .vi ? "Bỏ qua" : "Skip") + .font(CodepetTheme.inter(12, weight: .semibold)) + .foregroundColor(CodepetTheme.mutedText) } + .buttonStyle(.plain) } - .padding(12) - .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(CodepetTheme.surface)) - .frame(maxWidth: .infinity, alignment: .leading) Spacer(minLength: 24) } + .frame(maxWidth: .infinity, alignment: .leading) } /// The chat-run step-transparency indicator (web: a "producing" beat before the - /// draft card lands). Mirrors `CopilotChatView.typingRow`'s plain, no-bubble - /// style — not a filled chat bubble — so it reads as ambient status, not a - /// message. `CompanyStore.handleRunTaskId` removes this row (win or lose) - /// before appending the real reply, so it's always transient. - private var producingRow: some View { - HStack { - Text(lang == .vi ? "\(companionName) đang tổng hợp…" : "\(companionName) is putting that together…") - .font(.pixelSystem(size: 11)) - .foregroundColor(CodepetTheme.mutedText) - Spacer(minLength: 24) + /// draft card lands). Mirrors `ChatThinkingRow`'s plain, no-bubble style — not + /// a filled chat bubble — so it reads as ambient status, not a message. + /// `CompanyStore.handleRunTaskId` removes this row (win or lose) before + /// appending the real reply, so it's always transient. + @ViewBuilder private var producingRow: some View { + // With an execute-log, show the live step checklist (how the agent works); + // otherwise fall back to the plain thinking row. `text` carries the task + // title, `companionId` the acting specialist. + if let steps = message.execSteps, !steps.isEmpty { + ExecLogRow(taskTitle: message.text, deptName: message.deptName, + steps: steps, companionId: message.companionId) + } else { + ChatThinkingRow(taskTitle: message.text.isEmpty ? nil : message.text, + companionId: message.companionId) } } private var textBubble: some View { - HStack { - if isMe { Spacer(minLength: 24) } - Text(message.text) - .font(.pixelSystem(size: 12)) - .foregroundColor(isMe ? .white : CodepetTheme.primaryText) - .padding(.horizontal, 10).padding(.vertical, 7) - .background(RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(isMe ? CodepetTheme.accentPurple : CodepetTheme.surface)) - .fixedSize(horizontal: false, vertical: true) - if !isMe { Spacer(minLength: 24) } + Group { + if isMe { + HStack { + Spacer(minLength: 24) + Text(message.text) + .font(CodepetTheme.inter(15)) + .lineSpacing(4) + .foregroundColor(CodepetTheme.onAccent(CodepetTheme.accentPurple)) + .padding(.horizontal, 14).padding(.vertical, 9) + .background(UnevenRoundedRectangle( + cornerRadii: .init(topLeading: 14, bottomLeading: 14, + bottomTrailing: 4, topTrailing: 14), + style: .continuous).fill(CodepetTheme.accentPurple)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } else { + HStack(alignment: .top, spacing: 10) { + CompanionAvatar(companionId: message.companionId, size: 28) + VStack(alignment: .leading, spacing: 8) { + // A department specialist labels itself "Name · Dept" so the + // handoff reads as a real teammate stepping in. + if let dept = message.deptName, + let persona = message.companionId.flatMap({ PetCharacter.all[$0] }) { + Text("\(persona.name) · \(dept)") + .font(CodepetTheme.inter(12, weight: .semibold)) + .foregroundColor(persona.color) + } + Text(message.text) + .font(CodepetTheme.inter(15)) + .lineSpacing(4) + .foregroundColor(CodepetTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + // Hide copy/regenerate/thumbs until the reply has text — an + // empty streaming placeholder shouldn't show an action bar. + if !message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + companionActions + } + } + Spacer(minLength: 24) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + /// Copy + Regenerate on a companion text bubble. Regenerate is a pure + /// client-side resend of the last `me` message through the normal chat + /// path — no backend "regenerate" endpoint exists. + private var companionActions: some View { + HStack(spacing: 14) { + Button { copyText() } label: { + Image(systemName: "doc.on.doc").font(.system(size: 13)).foregroundColor(CodepetTheme.mutedText) + }.buttonStyle(.plain) + Button { regenerate() } label: { + Image(systemName: "arrow.clockwise").font(.system(size: 13)).foregroundColor(CodepetTheme.mutedText) + }.buttonStyle(.plain) + .disabled(companyStore.isCompanionTyping || companyStore.isStreaming) + Button { react(true) } label: { + Image(systemName: reaction == true ? "hand.thumbsup.fill" : "hand.thumbsup") + .font(.system(size: 13)) + .foregroundColor(reaction == true ? CodepetTheme.accentPurple : CodepetTheme.mutedText) + }.buttonStyle(.plain) + Button { react(false) } label: { + Image(systemName: reaction == false ? "hand.thumbsdown.fill" : "hand.thumbsdown") + .font(.system(size: 13)) + .foregroundColor(reaction == false ? CodepetTheme.accentPurple : CodepetTheme.mutedText) + }.buttonStyle(.plain) + } + } + + private func copyText() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(message.text, forType: .string) + } + + private func regenerate() { + guard !companyStore.isCompanionTyping, !companyStore.isStreaming else { return } + guard let lastUser = companyStore.chatMessages.last(where: { $0.role == .me })?.text else { return } + Task { await companyStore.sendChat(lastUser, language: lang) } + } + + private func react(_ helpful: Bool) { + reaction = helpful + companyStore.reactToMessage(messageId: message.id, helpful: helpful) + } + + /// A clean one-glance preview of a deliverable body for the card: drop leading + /// markdown heading lines (the card already shows the title), then strip inline + /// markdown markers so raw `##`/`**`/`` ` `` never leak into the preview. + static func previewText(_ body: String) -> String { + var lines = body.components(separatedBy: "\n") + // Drop leading blank + heading lines. + while let first = lines.first, + first.trimmingCharacters(in: .whitespaces).isEmpty + || first.trimmingCharacters(in: .whitespaces).hasPrefix("#") { + lines.removeFirst() } - .frame(maxWidth: .infinity, alignment: isMe ? .trailing : .leading) + let cleaned = lines.joined(separator: " ") + .replacingOccurrences(of: "**", with: "") + .replacingOccurrences(of: "`", with: "") + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "#", with: "") + return cleaned.trimmingCharacters(in: .whitespacesAndNewlines) } private func draftCard(_ d: Deliverable) -> some View { HStack { - CodepetCard { + MessageCard(hue: message.draftApproved ? CodepetTheme.accentTeal : CodepetTheme.accentGold) { VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Image(systemName: d.kind.icon).foregroundColor(CodepetTheme.accentPurple) Text(d.title) - .font(.pixelSystem(size: 12, weight: .semibold)) + .font(CodepetTheme.inter(15, weight: .semibold)) .foregroundColor(CodepetTheme.primaryText) } - Text(d.body) - .font(.pixelSystem(size: 11)) - .foregroundColor(CodepetTheme.mutedText) + Text(Self.previewText(d.body)) + .font(CodepetTheme.inter(13)) + .foregroundColor(CodepetTheme.bodyText) .lineLimit(3) .fixedSize(horizontal: false, vertical: true) } @@ -531,20 +638,20 @@ struct CopilotBubble: View { Image(systemName: "checkmark.circle.fill") Text(lang == .vi ? "Đã thêm vào Thư viện" : "Added to Library") } - .font(.pixelSystem(size: 10, weight: .semibold)) + .font(CodepetTheme.inter(12, weight: .semibold)) .foregroundColor(CodepetTheme.accentTeal) } else { HStack(spacing: 8) { Button { Task { await companyStore.approveDraft(messageId: message.id) } } label: { Text(lang == .vi ? "Duyệt" : "Approve") - .font(.pixelSystem(size: 10, weight: .semibold)) - .foregroundColor(.white) + .font(CodepetTheme.inter(12, weight: .semibold)) + .foregroundColor(CodepetTheme.onAccent(CodepetTheme.accentPurple)) .padding(.horizontal, 10).padding(.vertical, 4) .background(Capsule().fill(CodepetTheme.accentPurple)) }.buttonStyle(.plain) Button { Task { await companyStore.redoDraft(messageId: message.id, language: lang) } } label: { Text(lang == .vi ? "Làm lại" : "Redo") - .font(.pixelSystem(size: 10, weight: .semibold)) + .font(CodepetTheme.inter(12, weight: .semibold)) .foregroundColor(CodepetTheme.bodyText) .padding(.horizontal, 10).padding(.vertical, 4) .background(Capsule().stroke(CodepetTheme.hairline)) @@ -560,7 +667,7 @@ struct CopilotBubble: View { reviseNote: kind.note(lang)) } } label: { Text(kind.label(lang)) - .font(.pixelSystem(size: 9, weight: .semibold)) + .font(CodepetTheme.inter(11, weight: .semibold)) .foregroundColor(CodepetTheme.mutedText) .padding(.horizontal, 8).padding(.vertical, 3) .background(Capsule().stroke(CodepetTheme.hairline)) @@ -569,8 +676,6 @@ struct CopilotBubble: View { } } } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) } Spacer(minLength: 24) } diff --git a/codepet/Views/Copilot/MessageCard.swift b/codepet/Views/Copilot/MessageCard.swift new file mode 100644 index 0000000..71c8277 --- /dev/null +++ b/codepet/Views/Copilot/MessageCard.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// The one tinted-card construction shared by every interactive chat payload. +/// Only the hue varies (see MessageCardStyle): hue @12% over surface, a same-hue +/// 1pt border, smooth radius 12, uniform padding — matching the redesign's card +/// style. Left-aligned, fills the column width; callers add their own trailing +/// Spacer if they want the card to hug the leading edge. +struct MessageCard: View { + let hue: Color + @ViewBuilder var content: Content + + var body: some View { + content + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + // Opaque surface base FIRST, then the hue tint on top — i.e. "hue @12% + // over surface" per the spec. Without the surface base the card is only + // a translucent tint over ChatBackdrop's wash, washing out pale hues + // (gold/teal) and the reading text in light mode. + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(CodepetTheme.surface) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(hue.opacity(0.12))) + ) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(hue.opacity(0.9), lineWidth: 1)) + } +} diff --git a/codepet/Views/Copilot/Mocks/AgentsWorkingMock.swift b/codepet/Views/Copilot/Mocks/AgentsWorkingMock.swift new file mode 100644 index 0000000..a544080 --- /dev/null +++ b/codepet/Views/Copilot/Mocks/AgentsWorkingMock.swift @@ -0,0 +1,7 @@ +import SwiftUI + +#if DEBUG +#Preview("Agents · working in parallel") { + ChatMockData.agentsWorking().frame(width: 900, height: 720) +} +#endif diff --git a/codepet/Views/Copilot/Mocks/ChatEnvMock.swift b/codepet/Views/Copilot/Mocks/ChatEnvMock.swift new file mode 100644 index 0000000..e35f190 --- /dev/null +++ b/codepet/Views/Copilot/Mocks/ChatEnvMock.swift @@ -0,0 +1,7 @@ +import SwiftUI + +#if DEBUG +#Preview("Chat · setting up the environment") { + ChatMockData.conversation(ChatMockData.envScenario) +} +#endif diff --git a/codepet/Views/Copilot/Mocks/ChatMockData.swift b/codepet/Views/Copilot/Mocks/ChatMockData.swift new file mode 100644 index 0000000..b9afb75 --- /dev/null +++ b/codepet/Views/Copilot/Mocks/ChatMockData.swift @@ -0,0 +1,150 @@ +import SwiftUI + +#if DEBUG +/// Shared fixtures + renderer for the chat-scenario mocks. Feeds fixture messages +/// through the REAL `CopilotBubble` exactly like `CopilotChatView.messageList`, so +/// the mocks match production and cannot drift. The same fixtures back both the +/// Xcode `#Preview`s and the in-app dev gallery (`ChatMocksGalleryView`), so what +/// you click in the running app is byte-for-byte what the previews show. Layout +/// constants mirror the shipped spacing pass (column 760, gutter 24, top 40 / +/// bottom 24, spacing 24). +enum ChatMockData { + /// Core renderer — fills the space it's given (used by the resizable gallery). + static func messages(_ msgs: [CopilotMessage]) -> some View { + ZStack { + ChatBackdrop() + ScrollView { + VStack(alignment: .leading, spacing: 24) { + ForEach(msgs) { m in + CopilotBubble(message: m).id(m.id) + } + } + .padding(.top, 40) + .padding(.bottom, 24) + .frame(maxWidth: 760, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 24) + } + } + .environmentObject(CompanyStore()) + .environment(\.uiLanguage, .en) + } + + /// Preview convenience — the core renderer at a fixed canvas size. + static func conversation(_ msgs: [CopilotMessage]) -> some View { + messages(msgs).frame(width: 900, height: 720) + } + + /// The parallel agents-working block, rendered in the chat column like a + /// companion message (top 40 / bottom 24 / centered — matches `messages`). + static func agentsWorking() -> some View { + ZStack { + ChatBackdrop() + ScrollView { + AgentsWorkingRow(runs: agentRuns, now: agentsNow) + .frame(maxWidth: 760, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 40) + .padding(.bottom, 24) + .padding(.horizontal, 24) + } + } + .environmentObject(CompanyStore()) + .environment(\.uiLanguage, .en) + } + + // MARK: - Fixtures (shared by the previews and the in-app gallery) + + /// Chat working with the user: Q&A + a "Do it with me" first-run action. + static var userScenario: [CopilotMessage] { + [ + CopilotMessage(role: .me, text: "Which task should I run right now?"), + CopilotMessage(role: .companion, + text: "Your leverage right now is momentum, not polish. Ship the smallest thing a real user can touch this week: write your positioning in one sentence, book five short calls, and put a rough landing page in front of them. Want me to draft the positioning line?"), + CopilotMessage(role: .me, text: "draft it"), + CopilotMessage(role: .companion, + text: "On it — I can start this as a task and do the work with you.", + firstRunAction: FirstRunAction(taskId: "t1", + taskTitle: "Write your positioning statement")), + ] + } + + /// Chat working with the roadmap: a summary + a "Go to Roadmap" nav chip. + static var roadmapScenario: [CopilotMessage] { + [ + CopilotMessage(role: .me, text: "What's next on the roadmap?"), + CopilotMessage(role: .companion, + text: "You're in Validation. The one thing between you and launch is a working sign-up that captures real interest — everything else can wait. After that: pricing, then a small paid pilot."), + CopilotMessage(role: .companion, text: "", + navChip: NavAction(destination: "roadmap", target: nil)), + ] + } + + /// Chat working with tasks: a live exec-log then two draft cards (one approved). + static var tasksScenario: [CopilotMessage] { + [ + CopilotMessage(role: .me, text: "Run the landing page copy task."), + CopilotMessage(role: .companion, text: "Write your landing page copy", + producing: true, companionId: "nova", deptName: "Marketing", + execSteps: [ + ExecStep(label: "Reading your brief — mission, audience, voice", done: true), + ExecStep(label: "Pulling in the Marketing playbook", done: true), + ExecStep(label: "Drafting the headline and subhead", done: false), + ExecStep(label: "Matching your tone and past decisions", done: false), + ]), + CopilotMessage(role: .companion, text: "", + draft: Deliverable(kind: .post, title: "Landing page copy", + body: "Headline — Your AI cofounder, not another chatbot.\n\nSubhead — Codepet plans your next move, does the work with you, and remembers every decision — grounded in your actual company.")), + CopilotMessage(role: .companion, text: "", + draft: Deliverable(kind: .post, title: "Positioning statement", + body: "For solo founders who can code but stall on everything else, Codepet is the AI cofounder that runs the whole company with you."), + draftApproved: true), + ] + } + + /// Chat setting up the environment: a setup card + a "Noted" chip. + static var envScenario: [CopilotMessage] { + [ + CopilotMessage(role: .me, text: "Help me set up my tools."), + CopilotMessage(role: .companion, + text: "You'll want your code and your notes connected so I can act on them. Start with GitHub — it lets me open PRs and read your repo."), + CopilotMessage(role: .companion, text: "", + setupSuggestion: SetupAction(category: "connectors", name: "GitHub")), + CopilotMessage(role: .companion, text: "", + noted: [RememberedFact(topic: "Stack", + statement: "Ships a native macOS app in Swift")]), + ] + } + + /// A fixed clock so elapsed times are stable across preview reloads / launches. + static let agentsNow = Date(timeIntervalSince1970: 134) + + /// Three concurrent department agents — two working, one done. + static var agentRuns: [AgentRun] { + let base = Date(timeIntervalSince1970: 0) + return [ + AgentRun(companionId: "byte", deptName: "Engineering", + taskTitle: "Building the waitlist API", + steps: [ + ExecStep(label: "Scaffold the route", done: true), + ExecStep(label: "Define the schema", done: true), + ExecStep(label: "Write the handler", done: false), + ExecStep(label: "Add tests", done: false), + ], + status: .working, startedAt: base), + AgentRun(companionId: "luna", deptName: "Design", + taskTitle: "Landing hero visual pass", + steps: [ + ExecStep(label: "Moodboard", done: true), + ExecStep(label: "Layout", done: false), + ExecStep(label: "Typography", done: false), + ], + status: .working, startedAt: base), + AgentRun(companionId: "sage", deptName: "Legal", + taskTitle: "Privacy policy draft", + steps: [ExecStep(label: "Draft clauses", done: true)], + status: .done, startedAt: base), + ] + } +} +#endif diff --git a/codepet/Views/Copilot/Mocks/ChatMocksGalleryView.swift b/codepet/Views/Copilot/Mocks/ChatMocksGalleryView.swift new file mode 100644 index 0000000..57548c5 --- /dev/null +++ b/codepet/Views/Copilot/Mocks/ChatMocksGalleryView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +#if DEBUG +/// Dev-only switch for the in-app chat-mocks gallery. Enabled by launching with +/// `-CODEPET_MOCK_GALLERY YES` (or `defaults write app.murror.codepet +/// CODEPET_MOCK_GALLERY -bool YES`). Same idiom as `MockChat`. When on, the app +/// root becomes `ChatMocksGalleryView` instead of the normal shell — a way to +/// eyeball every chat mockup in a running build, not just the Xcode canvas. +enum MockGallery { + static var enabled: Bool { UserDefaults.standard.bool(forKey: "CODEPET_MOCK_GALLERY") } +} + +/// A sidebar picker over the five chat scenario mocks, rendering the selected one +/// live in the detail pane. Every scenario is backed by the same `ChatMockData` +/// fixtures the Xcode `#Preview`s use, so this is a faithful, clickable view of +/// exactly what was designed — including the parallel agents-working component. +struct ChatMocksGalleryView: View { + enum Scenario: String, CaseIterable, Identifiable { + case user = "With the user" + case roadmap = "With the roadmap" + case tasks = "With tasks" + case environment = "Environment setup" + case agents = "Agents working (parallel)" + var id: String { rawValue } + } + + @State private var selected: Scenario? = .user + + var body: some View { + NavigationSplitView { + List(Scenario.allCases, selection: $selected) { scenario in + Text(scenario.rawValue).tag(scenario) + } + .navigationTitle("Chat mocks") + .navigationSplitViewColumnWidth(min: 200, ideal: 220) + } detail: { + detail + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder private var detail: some View { + switch selected ?? .user { + case .user: ChatMockData.messages(ChatMockData.userScenario) + case .roadmap: ChatMockData.messages(ChatMockData.roadmapScenario) + case .tasks: ChatMockData.messages(ChatMockData.tasksScenario) + case .environment: ChatMockData.messages(ChatMockData.envScenario) + case .agents: ChatMockData.agentsWorking() + } + } +} + +#Preview("Chat mocks gallery") { + ChatMocksGalleryView() + .frame(width: 1100, height: 760) +} +#endif diff --git a/codepet/Views/Copilot/Mocks/ChatRoadmapMock.swift b/codepet/Views/Copilot/Mocks/ChatRoadmapMock.swift new file mode 100644 index 0000000..09f850e --- /dev/null +++ b/codepet/Views/Copilot/Mocks/ChatRoadmapMock.swift @@ -0,0 +1,7 @@ +import SwiftUI + +#if DEBUG +#Preview("Chat · with the roadmap") { + ChatMockData.conversation(ChatMockData.roadmapScenario) +} +#endif diff --git a/codepet/Views/Copilot/Mocks/ChatTasksMock.swift b/codepet/Views/Copilot/Mocks/ChatTasksMock.swift new file mode 100644 index 0000000..f39694f --- /dev/null +++ b/codepet/Views/Copilot/Mocks/ChatTasksMock.swift @@ -0,0 +1,7 @@ +import SwiftUI + +#if DEBUG +#Preview("Chat · with tasks") { + ChatMockData.conversation(ChatMockData.tasksScenario) +} +#endif diff --git a/codepet/Views/Copilot/Mocks/ChatUserMock.swift b/codepet/Views/Copilot/Mocks/ChatUserMock.swift new file mode 100644 index 0000000..8f37fcd --- /dev/null +++ b/codepet/Views/Copilot/Mocks/ChatUserMock.swift @@ -0,0 +1,7 @@ +import SwiftUI + +#if DEBUG +#Preview("Chat · with the user") { + ChatMockData.conversation(ChatMockData.userScenario) +} +#endif diff --git a/codepet/Views/Copilot/QuickAction.swift b/codepet/Views/Copilot/QuickAction.swift new file mode 100644 index 0000000..34c9a9f --- /dev/null +++ b/codepet/Views/Copilot/QuickAction.swift @@ -0,0 +1,12 @@ +import Foundation + +/// A capability quick-action: a localized title (also the message sent) + an +/// SF Symbol shown on the card/pill and in the composer's `+` menu, plus a +/// short localized helper description shown on the card. The detail is plain +/// display text — it is not separately selectable; the whole card is the tap target. +struct QuickAction: Identifiable { + let id = UUID() + let title: String + let systemImage: String + let detail: String +} diff --git a/codepet/Views/Onboarding/OnboardingAnalysisView.swift b/codepet/Views/Onboarding/OnboardingAnalysisView.swift index 835d821..662012f 100644 --- a/codepet/Views/Onboarding/OnboardingAnalysisView.swift +++ b/codepet/Views/Onboarding/OnboardingAnalysisView.swift @@ -15,9 +15,11 @@ struct OnboardingAnalysisView: View { Text("Codepet is reading \(projectName.isEmpty ? "your project" : projectName)…") .font(CodepetTheme.body(20, weight: .semibold)) .foregroundColor(CodepetTheme.primaryText) + .riseIn(delay: OnboardingMotion.stepHeadingDelay) Text("Turning what you told me into a full company plan.") .font(CodepetTheme.body(14)).foregroundColor(CodepetTheme.bodyText) .padding(.top, 9) + .riseIn(delay: OnboardingMotion.stepSubDelay) VStack(alignment: .leading, spacing: 0) { ForEach(0.. Animation { + .timingCurve(0.2, 0.7, 0.2, 1, duration: duration) + } + + // riseIn durations + static let riseSplash: Double = 0.9 // .splash-title / -btn / -hint + static let riseWord: Double = 0.7 // .splash-sub .w + static let riseCold: Double = 0.85 // .ob-cold-in > * + static let riseStep: Double = 0.55 // .ob-body > * + + // riseIn delays + static let splashTitleDelay: Double = 0.15 + static let splashButtonDelay: Double = 0.48 + static let splashHintDelay: Double = 0.66 + static let coldHeadlineDelay: Double = 0.10 + static let coldParagraphDelay: Double = 0.24 + static let coldChipsDelay: Double = 0.50 + static let stepHeadingDelay: Double = 0.04 + static let stepSubDelay: Double = 0.12 + static let stepRestDelay: Double = 0.20 + + /// `.splash-sub .w { animation-delay: calc(.32s + var(--i) * 60ms) }` + static func wordDelay(_ index: Int) -> Double { + 0.32 + Double(index) * 0.06 + } + + // Ken Burns — `.splash:before` 30s, `.ob-cold:after` 32s, both infinite alternate. + static let kenBurnsSplash: Double = 30 + static let kenBurnsCold: Double = 32 + static let kenBurnsScale: CGFloat = 1.08 + /// translate(-1.2%, -1.6%) of the layer's own size, at full drift. + static func kenBurnsDrift(width: CGFloat, height: CGFloat) -> CGSize { + CGSize(width: -width * 0.012, height: -height * 0.016) + } + + // titleGlow — 5s ease-in-out, 1.4s delay, infinite. CSS blur px ≈ 2× SwiftUI radius. + static let glowPeriod: Double = 5 + static let glowDelay: Double = 1.4 + static let glowRadiusLow: CGFloat = 15 // 30px + static let glowRadiusHigh: CGFloat = 26 // 52px + static let glowOpacityLow: Double = 0.45 + static let glowOpacityHigh: Double = 0.78 + + // titleSweep — 4.6s total: crosses over 55% (2.53s), then holds (2.07s). 1.7s delay. + static let sweepCross: Double = 2.53 + static let sweepHold: Double = 2.07 + static let sweepDelay: Double = 1.7 + static let sweepFrom: CGFloat = -1.2 + static let sweepTo: CGFloat = 1.2 + + // hintPulse — 2.8s ease-in-out, 1.6s delay, infinite. + static let hintPeriod: Double = 2.8 + static let hintDelay: Double = 1.6 + static let hintOpacityLow: Double = 0.42 + static let hintOpacityHigh: Double = 0.72 + + // .ob-art span — `transition: opacity 1.1s, transform 7s`, scale 1.07 → 1. + // The web also offsets these by the pointer; that parallax is deliberately not + // ported — mouse-tracking movement on the art was distracting in use. + static let artCrossfade: Double = 1.1 + static let artSettle: Double = 7 + static let artEnterScale: CGFloat = 1.07 +} + +// MARK: - riseIn + +/// `@keyframes riseIn` — fade up 16pt on the shared entrance curve. +private struct RiseIn: ViewModifier { + let duration: Double + let delay: Double + @State private var shown = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + let on = shown || reduceMotion + return content + .opacity(on ? 1 : 0) + .offset(y: on ? 0 : 16) + .onAppear { + guard !reduceMotion else { return } + withAnimation(OnboardingMotion.entrance(duration).delay(delay)) { shown = true } + } + } +} + +extension View { + /// Web `riseIn`. Defaults to the step-content timing (`.ob-body > *`). + func riseIn(_ duration: Double = OnboardingMotion.riseStep, delay: Double = 0) -> some View { + modifier(RiseIn(duration: duration, delay: delay)) + } +} + +// MARK: - looping accents + +/// `@keyframes hintPulse` — opacity breathing on the splash hint line. +private struct HintPulse: ViewModifier { + @State private var up = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content + .opacity(reduceMotion ? OnboardingMotion.hintOpacityHigh + : (up ? OnboardingMotion.hintOpacityHigh : OnboardingMotion.hintOpacityLow)) + .onAppear { + guard !reduceMotion else { return } + let a = Animation.easeInOut(duration: OnboardingMotion.hintPeriod / 2) + .repeatForever(autoreverses: true) + .delay(OnboardingMotion.hintDelay) + withAnimation(a) { up = true } + } + } +} + +extension View { + func hintPulse() -> some View { modifier(HintPulse()) } +} + +/// `@keyframes titleSweep` — a specular bar crossing the title, then pausing. +/// Masked to the content so it only lights the glyphs, like the web's `:after` on the +/// title with `background-clip`. +struct TitleSweep: View { + let mask: Mask + @State private var armed = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + GeometryReader { geo in + let w = geo.size.width + let bar = LinearGradient( + colors: [.clear, Color.white.opacity(0.55), .clear], + startPoint: .leading, endPoint: .trailing + ) + .frame(width: max(40, w * 0.35)) + .blendMode(.plusLighter) + + if armed && !reduceMotion { + bar.keyframeAnimator(initialValue: OnboardingMotion.sweepFrom, repeating: true) { view, x in + view.offset(x: x * w) + } keyframes: { _ in + CubicKeyframe(OnboardingMotion.sweepTo, duration: OnboardingMotion.sweepCross) + LinearKeyframe(OnboardingMotion.sweepTo, duration: OnboardingMotion.sweepHold) + } + } + } + .mask(mask) + .allowsHitTesting(false) + .task { + guard !reduceMotion else { return } + try? await Task.sleep(nanoseconds: UInt64(OnboardingMotion.sweepDelay * 1_000_000_000)) + armed = true + } + } +} + +// MARK: - art panel + +/// One layer of `.ob-art`. Enters at `scale(1.07)` and settles to 1 over 7s while the +/// ZStack crossfades it in over 1.1s — the web's split +/// `transition: opacity 1.1s, transform 7s`. No pointer parallax by design. +struct OnboardingArtLayer: View { + let name: String + let width: CGFloat + let grade: Color + + @State private var settled = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let scale: CGFloat = (settled || reduceMotion) ? 1.0 : OnboardingMotion.artEnterScale + return Image(name) + .resizable() + .interpolation(.high) + .scaledToFill() + .frame(width: width) + .frame(maxHeight: .infinity) + .scaleEffect(scale) + .clipped() + .overlay(grade.blendMode(.softLight)) + .compositingGroup() + .onAppear { + guard !reduceMotion else { return } + withAnimation(.easeOut(duration: OnboardingMotion.artSettle)) { settled = true } + } + } +} diff --git a/codepet/Views/Onboarding/OnboardingStageSlider.swift b/codepet/Views/Onboarding/OnboardingStageSlider.swift index 7f539cb..1fd6a04 100644 --- a/codepet/Views/Onboarding/OnboardingStageSlider.swift +++ b/codepet/Views/Onboarding/OnboardingStageSlider.swift @@ -9,6 +9,21 @@ enum StageSliderMath { let f = max(0, min(1, x / width)) return Int((f * CGFloat(count - 1)).rounded()) } + + /// Usable track span inside the thumb inset (web `.sb-track { inset: 0 15px }`). + static func trackWidth(container: CGFloat, inset: CGFloat) -> CGFloat { + max(1, container - inset * 2) + } + + /// Centre x of a stage, in container coordinates. Drawing and hit-testing must both + /// go through this + `trackWidth`; using the full width for one and the inset track + /// for the other makes the thumb lag the cursor. + static func centerX(forIndex index: Int, container: CGFloat, inset: CGFloat, count: Int) -> CGFloat { + guard count > 1 else { return inset } + let track = trackWidth(container: container, inset: inset) + let frac = CGFloat(index) / CGFloat(count - 1) + return inset + track * frac + } } /// The stage step's draggable ruler (web `StageBar` + `.rngticks` + `.obnote`). @@ -16,24 +31,33 @@ enum StageSliderMath { struct OnboardingStageSlider: View { @Binding var stageIndex: Int @State private var dragging = false + @FocusState private var focused: Bool private let stages = OnboardingContent.stages private var n: Int { stages.count } private let step = 4 // minor ticks between stages + /// Web `.sb-track { inset: 0 15px }` — half the 26pt thumb plus a little, so the + /// thumb is fully inside the panel at both ends instead of being sliced by the edge. + private let trackInset: CGFloat = 15 var body: some View { VStack(alignment: .leading, spacing: 10) { GeometryReader { geo in - let w = geo.size.width + // Everything is positioned against the inset track, not the full width; + // the drag mapping below uses the same span so the thumb tracks the cursor. + let track = StageSliderMath.trackWidth(container: geo.size.width, inset: trackInset) let frac = n > 1 ? CGFloat(stageIndex) / CGFloat(n - 1) : 0 ZStack(alignment: .leading) { // base track - Capsule().fill(OnboardingContent.Palette.well).frame(height: 3) + Capsule().fill(OnboardingContent.Palette.well) + .frame(width: track, height: 3) + .offset(x: trackInset) // progress Capsule() .fill(LinearGradient(colors: [CodepetTheme.accentPurple, OnboardingContent.Palette.accentDeep], startPoint: .leading, endPoint: .trailing)) - .frame(width: max(0, w * frac), height: 3) + .frame(width: max(0, track * frac), height: 3) + .offset(x: trackInset) // ticks ForEach(0...((n - 1) * step), id: \.self) { t in let tf = CGFloat(t) / CGFloat((n - 1) * step) @@ -41,30 +65,45 @@ struct OnboardingStageSlider: View { let filled = tf <= frac + 0.001 Capsule() .fill(filled ? (isMajor ? OnboardingContent.Palette.accentDeep : CodepetTheme.accentPurple) - : Color(hex: isMajor ? "#cbc3b2" : "#dad3c5")) + : (isMajor ? OnboardingContent.Palette.tickMajor + : OnboardingContent.Palette.tickMinor)) .frame(width: isMajor ? 2.5 : 2, height: isMajor ? 18 : 9) - .position(x: w * tf, y: 24) + .position(x: trackInset + track * tf, y: 24) } - // thumb + // thumb — focus shows here as a soft halo (web + // `.stagebar:focus-visible .sb-thumb { box-shadow: 0 0 0 5px … }`), + // never as a rectangle around the whole control. Circle() .fill(Color.white) .overlay(Circle().stroke(CodepetTheme.accentPurple, lineWidth: 3)) .overlay(Circle().fill(CodepetTheme.accentPurple).padding(6)) .frame(width: 26, height: 26) + .overlay { + if focused { + Circle() + .stroke(CodepetTheme.accentPurple.opacity(0.18), lineWidth: 5) + .frame(width: 31, height: 31) + } + } .shadow(color: CodepetTheme.accentPurple.opacity(0.4), radius: 6, y: 4) - .position(x: w * frac, y: 24) + .position(x: trackInset + track * frac, y: 24) } .frame(height: 48) .contentShape(Rectangle()) .gesture(DragGesture(minimumDistance: 0) .onChanged { v in dragging = true - stageIndex = StageSliderMath.stageIndex(atX: v.location.x, width: w, count: n) + stageIndex = StageSliderMath.stageIndex(atX: v.location.x - trackInset, + width: track, count: n) } .onEnded { _ in dragging = false }) } .frame(height: 48) .focusable(true) + .focused($focused) + // Web `.stagebar { outline: none }` — suppress the system focus ring; the + // thumb halo above is the focus affordance. + .focusEffectDisabled() .onMoveCommand { dir in if dir == .right { stageIndex = min(n - 1, stageIndex + 1) } if dir == .left { stageIndex = max(0, stageIndex - 1) } diff --git a/codepet/Views/Onboarding/OnboardingView.swift b/codepet/Views/Onboarding/OnboardingView.swift index f047fbd..8c9d27b 100644 --- a/codepet/Views/Onboarding/OnboardingView.swift +++ b/codepet/Views/Onboarding/OnboardingView.swift @@ -1,9 +1,40 @@ // codepet/Views/Onboarding/OnboardingView.swift import SwiftUI -/// First-run cinematic onboarding — faithful English-only port of the web -/// `Onboarding` (8 steps 0–7). Replaces the 6-field CompanyOnboardingView at -/// first run; the reveal/scaffold is fail-open (scaffoldRoadmap CF undeployed). +/// Pure layout maths for the onboarding chrome. The web sizes these fluidly (a +/// percentage or a CSS `clamp()`); the first native port froze them at the values +/// they happen to take at the ~860pt design width, which is why the flow degraded +/// as the window grew. Extracted for unit testing, like `StageSliderMath`. +enum OnboardingLayout { + /// Web: `.ob-art { width: 42% }` — a straight percentage with no ceiling, so the + /// art keeps its share of the window at any size. Only a lower bound is applied, + /// to keep the panel legible (and the form solvent) at the 560pt minimum window. + /// + /// An upper clamp was tried and removed: capping at 620 put the panel at 24% of a + /// 2560pt display, which is the same collapsed composition the fixed 360pt width + /// caused — just less severe. Verified against a render, not arithmetic. + static func artWidth(container: CGFloat) -> CGFloat { + max(320, container * 0.42) + } + /// Web: `.ob-cold-in h1 { font-size: clamp(34px, 4vw, 52px) }`. + static func coldHeadline(container: CGFloat) -> CGFloat { + min(52, max(34, container * 0.04)) + } + /// Web: `.ob-cold-in { margin-left: clamp(40px, 9vw, 150px) }`. + static func coldLeading(container: CGFloat) -> CGFloat { + min(150, max(40, container * 0.09)) + } +} + +/// First-run cinematic onboarding — English-only port of the web `Onboarding`, +/// 8 steps 0–7: cold open → name → role → tech → project → stage → analysis → reveal. +/// Replaces the 6-field CompanyOnboardingView at first run; the reveal/scaffold is +/// fail-open (scaffoldRoadmap CF undeployed). +/// +/// Deliberate divergence from the web: the web's 9th step, the companion picker, is +/// cut. Choosing a pet before meeting any of them is a decision without information, +/// and it sat between the reveal and getting to work. The company keeps its default +/// companion and the picker lives in Settings. struct OnboardingView: View { @EnvironmentObject var companyStore: CompanyStore @EnvironmentObject var appState: AppState @@ -13,7 +44,6 @@ struct OnboardingView: View { var projName = "", oneLiner = "", audience = "", link = "", notes = "" var categories: [String] = [] var stageIndex = OnboardingContent.defaultStageIndex - var pick = "" } @State private var step = 0 @@ -52,60 +82,93 @@ struct OnboardingView: View { } } .background(CodepetTheme.pageBackground.ignoresSafeArea()) - .onAppear { if d.pick.isEmpty { d.pick = companyStore.company.companionId } } .onChange(of: step) { newStep in // Autofocus the name field when entering step 1 (deferred so the field is mounted). if newStep == 1 { DispatchQueue.main.async { nameFocused = true } } } } - // Two-panel card: art left (42%), form right. + /// Web `.ob-body.tall` — the project step top-aligns instead of centring. + private var isTallStep: Bool { step == 4 } + + // Two-panel card, mirroring the web `.obcard`: + // `.ob-art { flex: none; width: 42% }` — proportional, not a fixed width + // `.ob-main { flex: 1; overflow: auto }` — every step scrolls + // `.ob-top / .ob-body / .ob-foot { max-width: 600px }` + // Skip is deliberately NOT in the top row: on the web it's `.skip-pre`, absolutely + // positioned against the whole screen, so Back never gets pushed away from it. private var card: some View { - HStack(spacing: 0) { - Image(OnboardingContent.stepArt[min(step, OnboardingContent.stepArt.count - 1)]) - .resizable().interpolation(.high).scaledToFill() - .frame(width: 360) - .frame(maxHeight: .infinity) - .clipped() - .overlay( - OnboardingContent.stepGrade[min(step, OnboardingContent.stepGrade.count - 1)] - .blendMode(.softLight) - ) - .compositingGroup() // isolate the soft-light blend to the art panel - .id(step) // re-fade on step change - Divider() - VStack(alignment: .leading, spacing: 0) { - HStack { - if step != 6 { - Button(action: { step = max(0, step - 1) }) { - Text("← Back").font(CodepetTheme.body(12)).foregroundColor(CodepetTheme.mutedText) - }.buttonStyle(.plain) - } - Spacer() - Button(action: skip) { - Text("Skip onboarding →").font(CodepetTheme.body(12)).foregroundColor(CodepetTheme.mutedText) - }.buttonStyle(.plain) + GeometryReader { card in + HStack(spacing: 0) { + // `.ob-art span` — layers crossfade over 1.1s while the incoming one + // settles from scale(1.07) to 1 over 7s. The web also drifts these with + // the pointer; not ported (distracting in use). + // The .id drives the ZStack transition, so only two layers are ever live. + ZStack { + OnboardingArtLayer( + name: OnboardingContent.stepArt[min(step, OnboardingContent.stepArt.count - 1)], + width: OnboardingLayout.artWidth(container: card.size.width), + grade: OnboardingContent.stepGrade[min(step, OnboardingContent.stepGrade.count - 1)] + ) + .id(step) + .transition(.opacity) } - .padding(.bottom, 8) + .animation(.easeInOut(duration: OnboardingMotion.artCrossfade), value: step) + Divider() + VStack(alignment: .leading, spacing: 0) { + // `.ob-top` — Back only, held to the same 600pt measure as the body + // and footer so the controls stay with the content on a wide window. + HStack { + if step != 6 { + Button(action: { step = max(0, step - 1) }) { + Text("← Back").font(CodepetTheme.body(12)).foregroundColor(CodepetTheme.mutedText) + }.buttonStyle(.plain) + } + Spacer(minLength: 0) + } + .frame(maxWidth: 600) + .padding(.bottom, 8) - Group { - if step == 4 || step == 8 { // tall: project + companion → top-align + scroll - ScrollView { stepBody.frame(maxWidth: 600, alignment: .leading) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } else { // vertically centered (.leading = leading + center-vertical) - stepBody.frame(maxWidth: 600, alignment: .leading) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + // `.ob-main { overflow: auto }` + `.ob-body { justify-content: center }`: + // the step column is centred while it fits and scrolls once it doesn't, + // so a long reveal (server-driven task count) can always reach the footer. + GeometryReader { area in + ScrollView { + // .id(step) gives each step fresh views so the `riseIn` + // stagger replays on advance, as new DOM nodes do on the web. + VStack(alignment: .leading, spacing: 0) { stepBody } + .id(step) + .frame(maxWidth: 600, alignment: .leading) + .frame(minHeight: area.size.height, + alignment: isTallStep ? .top : .center) + } + .frame(maxWidth: .infinity, alignment: .leading) } - } - footer.frame(maxWidth: 600) + footer.frame(maxWidth: 600) + } + .padding(.horizontal, 64).padding(.vertical, 40) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .padding(.horizontal, 64).padding(.vertical, 40) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay(alignment: .topTrailing) { skipPill } } .background(CodepetTheme.surface) } + /// Web `.ob .skip-pre` — a pill pinned to the card's top-trailing corner. + private var skipPill: some View { + Button(action: skip) { + Text("Skip onboarding →") + .font(CodepetTheme.body(12)).fontWeight(.semibold) + .foregroundColor(CodepetTheme.mutedText) + .padding(.horizontal, 14).padding(.vertical, 7) + .background(Capsule().fill(CodepetTheme.surface)) + .overlay(Capsule().stroke(CodepetTheme.hairline, lineWidth: 1)) + } + .buttonStyle(.plain) + .padding(.top, 20).padding(.trailing, 24) + } + @ViewBuilder private var stepBody: some View { switch step { case 1: @@ -119,9 +182,11 @@ struct OnboardingView: View { OnboardingOptionList(options: OnboardingContent.roles, selectedKey: Binding( get: { d.role }, set: { k in d.role = k; d.roleLabel = OnboardingContent.roles.first(where: { $0.key == k })?.label ?? "" })) + .riseIn(delay: OnboardingMotion.stepRestDelay) case 3: heading("How hands-on are you with the code?", "So I know how deep to go on the technical side.") OnboardingOptionList(options: OnboardingContent.tech, selectedKey: $d.tech) + .riseIn(delay: OnboardingMotion.stepRestDelay) case 4: heading("Now — what are you building?", "A name and one clear sentence — that line is what I read to tailor your whole plan. Everything else is optional but sharpens it.") @@ -132,6 +197,7 @@ struct OnboardingView: View { chips(OnboardingContent.categories, selected: d.categories) { c in if d.categories.contains(c) { d.categories.removeAll { $0 == c } } else { d.categories.append(c) } } + .riseIn(delay: OnboardingMotion.stepRestDelay) label("Who's it for? (optional)") textField("e.g. solo founders shipping their first product", text: $d.audience) label("Link (optional — website, repo, or Figma)") @@ -142,15 +208,17 @@ struct OnboardingView: View { .scrollContentBackground(.hidden) // hide TextEditor's default backing (macOS 13+) .padding(8).background(RoundedRectangle(cornerRadius: 12).fill(OnboardingContent.Palette.surface2)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(CodepetTheme.hairline, lineWidth: 1)) + .riseIn(delay: OnboardingMotion.stepRestDelay) case 5: heading("Where are you today?", "This sets your starting point on the roadmap.") OnboardingStageSlider(stageIndex: $d.stageIndex) + .riseIn(delay: OnboardingMotion.stepRestDelay) case 6: OnboardingAnalysisView(projectName: d.projName, shown: anShown, done: anDone) - case 7: + .riseIn(delay: OnboardingMotion.stepRestDelay) + default: // 7 — the reveal is the last screen; "Start building" finishes here. OnboardingRevealView(name: d.name, roleLabel: d.roleLabel, stageIndex: d.stageIndex, reveal: reveal ?? .empty) - default: - OnboardingCompanionStep(pickedId: $d.pick) + .riseIn(delay: OnboardingMotion.stepRestDelay) } } @@ -185,8 +253,7 @@ struct OnboardingView: View { case 4: bigButton("Continue", enabled: !d.projName.trimmed.isEmpty && !d.oneLiner.trimmed.isEmpty) { step = 5 } case 5: bigButton("Analyze my project", enabled: true) { startAnalysis() } case 6: if anDone && reveal != nil { bigButton("See what I found", enabled: true) { step = 7 } } - case 7: bigButton("Choose your companion", enabled: true) { step = 8 } - default: bigButton("Start building", enabled: true) { finishWithCompanion() } + default: bigButton("Start building", enabled: true) { finish() } } } @@ -223,17 +290,18 @@ struct OnboardingView: View { } } - private func finishWithCompanion() { + private func finish() { streamTask?.cancel(); scaffoldTask?.cancel(); timeoutTask?.cancel() let token = companyStore.onboardingToken - let id = d.pick.isEmpty ? companyStore.company.companionId : d.pick Task { - await companyStore.setCompanion(id: id) - appState.activeChar = id + // No companion picker in the flow any more, so nothing to persist here — + // the company keeps its default companion. Still mirror it onto appState so + // the app opens with the same character the store holds (Settings changes it). + appState.activeChar = companyStore.company.companionId // Pass the store's current (already-enriched, by scaffoldFromOnboarding) // brief — NOT the local raw `brief()` draft — so finishOnboarding doesn't // clobber the enriched summary/audience/categories with unenriched values. - // Steps 6-8 never edit brief fields, so company.brief is authoritative here; + // Steps 6-7 never edit brief fields, so company.brief is authoritative here; // if enrichment failed (fail-open) it already equals the raw brief, so this // is safe in all cases. EXCEPT: if "Start building" was reached while the // scaffold Task was still in-flight, the `scaffoldTask?.cancel()` above can @@ -254,16 +322,20 @@ struct OnboardingView: View { // MARK: small view helpers + // `.ob-body > *` staggering: h2 at 40ms, p at .12s, everything after at .2s. private func heading(_ h: String, _ sub: String) -> some View { VStack(alignment: .leading, spacing: 9) { Text(h).font(CodepetTheme.body(20, weight: .semibold)).foregroundColor(CodepetTheme.primaryText) + .riseIn(delay: OnboardingMotion.stepHeadingDelay) Text(sub).font(CodepetTheme.body(14)).foregroundColor(CodepetTheme.bodyText) + .riseIn(delay: OnboardingMotion.stepSubDelay) }.padding(.bottom, 4) } private func label(_ t: String) -> some View { Text(t).font(CodepetTheme.body(12)).fontWeight(.semibold) .foregroundColor(CodepetTheme.primaryText).padding(.top, 18).padding(.bottom, 8) .frame(maxWidth: .infinity, alignment: .leading) + .riseIn(delay: OnboardingMotion.stepRestDelay) } private func textField(_ ph: String, text: Binding) -> some View { TextField(ph, text: text) @@ -271,6 +343,7 @@ struct OnboardingView: View { .padding(.horizontal, 14).padding(.vertical, 13) .background(RoundedRectangle(cornerRadius: 12).fill(OnboardingContent.Palette.surface2)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(CodepetTheme.hairline, lineWidth: 1)) + .riseIn(delay: OnboardingMotion.stepRestDelay) } private func chips(_ items: [String], selected: [String], toggle: @escaping (String) -> Void) -> some View { ChipFlowLayout(spacing: 8) { diff --git a/codepet/Views/Onboarding/Starfield.swift b/codepet/Views/Onboarding/Starfield.swift index bd9627a..752c115 100644 --- a/codepet/Views/Onboarding/Starfield.swift +++ b/codepet/Views/Onboarding/Starfield.swift @@ -52,9 +52,11 @@ struct Starfield: View { let dot: Dot @State private var bright = false var body: some View { + // Web `@keyframes twinkle`: opacity .1 ↔ .8 AND translateY(0) ↔ -6px. Circle() - .fill(Color.white.opacity(bright ? 0.9 : 0.25)) + .fill(Color.white.opacity(bright ? 0.8 : 0.1)) .frame(width: dot.size, height: dot.size) + .offset(y: bright ? -6 : 0) .onAppear { withAnimation(.easeInOut(duration: dot.dur) .repeatForever(autoreverses: true).delay(dot.delay)) { diff --git a/codepet/Views/Shell/AppShellView.swift b/codepet/Views/Shell/AppShellView.swift index d4912eb..d8605b4 100644 --- a/codepet/Views/Shell/AppShellView.swift +++ b/codepet/Views/Shell/AppShellView.swift @@ -1,34 +1,39 @@ // codepet/Views/Shell/AppShellView.swift import SwiftUI -/// The app's top-level shell — a left navigation rail (`AppRailView`), a slim top -/// bar carrying the current destination's name plus the wake pill and Upgrade -/// button, and a content area switching on the store's view. Chat is the default -/// destination and occupies the full content area; it is no longer a docked panel. -/// Styled in CodepetTheme; the rail's selected item and accents follow the active -/// companion's color. +/// The app's top-level shell — a native port of the web AppRoot: the left +/// `SidebarView` (brand, New chat, Recent, Workspace nav, Upgrade/account) and a +/// full-width content area switching on the store's view (chat is the default). +/// Styled in CodepetTheme. struct AppShellView: View { @EnvironmentObject var companyStore: CompanyStore - @EnvironmentObject var appState: AppState @Environment(\.uiLanguage) private var uiLanguage - private var accent: Color { PetCharacter.all[appState.activeChar]?.color ?? CodepetTheme.accentPurple } + @State private var sidebarCollapsed = false var body: some View { HStack(spacing: 0) { - AppRailView(accent: accent) - VStack(spacing: 0) { - topBar + if !sidebarCollapsed { + SidebarView(collapsed: $sidebarCollapsed) Divider() - content.frame(maxWidth: .infinity, maxHeight: .infinity) } + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .topLeading) { + if sidebarCollapsed { + Button { sidebarCollapsed = false } label: { + Image(systemName: "sidebar.left").font(.system(size: 15)) + .foregroundColor(CodepetTheme.bodyText).padding(10) + }.buttonStyle(.plain).padding(8) + } + } } .background(CodepetTheme.pageBackground) } @ViewBuilder private var content: some View { if companyStore.view == .chat { - CopilotChatView() + CopilotChatView(sidebarCollapsed: sidebarCollapsed) } else if companyStore.view == .roadmap { RoadmapView() } else if companyStore.view == .secondBrain { @@ -55,42 +60,6 @@ struct AppShellView: View { ShellPlaceholderView(view: companyStore.view) } } - - /// Slim top bar: the destination title on the left, wake pill and Upgrade on - /// the right. Navigation itself lives in the rail. - private var topBar: some View { - HStack(spacing: 14) { - Text(companyStore.view.title(uiLanguage)) - .font(CodepetTheme.inter(15, weight: .semibold)) - .foregroundColor(CodepetTheme.primaryText) - Spacer(minLength: 20) - HStack(spacing: 10) { - wakePill - Button { companyStore.selectedDeptKey = nil; companyStore.select(.billing) } label: { - Text(uiLanguage == .vi ? "Nâng cấp" : "Upgrade") - .font(CodepetTheme.inter(13.5, weight: .semibold)).foregroundColor(.white) - .padding(.horizontal, 13).padding(.vertical, 7) - .background(Capsule().fill(CodepetTheme.primaryText)) - }.buttonStyle(.plain) - } - } - .padding(.horizontal, 16).padding(.vertical, 10) - } - - private var companionName: String { PetCharacter.all[companyStore.company.companionId]?.name ?? "Codepet" } - - private var wakePill: some View { - Button { companyStore.selectedDeptKey = nil; companyStore.select(.environment) } label: { - HStack(spacing: 5) { - Circle().fill(CodepetTheme.accentOrange).frame(width: 6, height: 6) - Text("⚡ " + (uiLanguage == .vi ? "Đánh thức \(companionName)" : "Wake \(companionName) up")) - .font(CodepetTheme.inter(13.5, weight: .medium)) - } - .foregroundColor(CodepetTheme.bodyText) - .padding(.horizontal, 12).padding(.vertical, 6) - .background(Capsule().fill(accent.opacity(0.1))) - }.buttonStyle(.plain) - } } /// Placeholder content per destination — the real views land in later phases. diff --git a/codepet/Views/Shell/SidebarView.swift b/codepet/Views/Shell/SidebarView.swift new file mode 100644 index 0000000..885d359 --- /dev/null +++ b/codepet/Views/Shell/SidebarView.swift @@ -0,0 +1,388 @@ +// codepet/Views/Shell/SidebarView.swift +import SwiftUI + +/// Task SB-1 (sidebar restructure): a ~250pt left navigation column that +/// replaces the old top bar — brand/home, "New chat", the optimized "Recent" +/// thread history (was the in-chat History toggle), the Workspace nav (was the +/// top-bar tabs), and a pinned Upgrade/account footer. Native port of the +/// purple-brand mock (`scratchpad/chat-v4-sidebar.png`). +/// +/// Wired in `AppShellView`, which renders this view alongside the full-width +/// `content` and owns the `collapsed` state (see the `collapsed` binding below). +struct SidebarView: View { + @EnvironmentObject var companyStore: CompanyStore + @Environment(\.uiLanguage) private var lang + + /// Collapse affordance — the real binding is hosted in `AppShellView`'s + /// `@State sidebarCollapsed`; the chevron here just flips it (the shell then + /// hides this view and shows a reveal button over the content). + @Binding var collapsed: Bool + + @State private var renamingId: String? + @State private var renameDraft = "" + /// Measured height of the Recent list's content — a ScrollView is greedy + /// along its scroll axis, so without this it would hold the full + /// `recentMaxHeight` even with two threads and leave dead space above the + /// Workspace divider. See `recentSection`. + @State private var recentContentHeight: CGFloat = 0 + // Stamped on appear so relative times don't recompute on every re-render — + // mirrors `ThreadListView`'s own `now`. + @State private var now = Date() + + private var isChatBusy: Bool { + companyStore.isCompanionTyping || companyStore.isStreaming + } + + /// Ceiling for the Recent list before it starts scrolling. + private static let recentMaxHeight: CGFloat = 260 + + private var newChatGradient: LinearGradient { + LinearGradient(colors: [CodepetTheme.accentPurple, CodepetTheme.accentPink], + startPoint: .leading, endPoint: .trailing) + } + + var body: some View { + VStack(spacing: 0) { + brandRow + newChatButton + recentSection + Divider().padding(.horizontal, 14).padding(.vertical, 8) + workspaceSection + Spacer(minLength: 0) + bottomSection + } + .frame(width: 250) + .frame(maxHeight: .infinity, alignment: .top) + .background(CodepetTheme.surface) + .overlay(alignment: .trailing) { + Rectangle().fill(CodepetTheme.hairline).frame(width: 1) + } + .onAppear { now = Date() } + } + + // MARK: - Brand row + + private var brandRow: some View { + HStack(spacing: 8) { + Button { + companyStore.selectedDeptKey = nil + companyStore.select(.chat) + } label: { + Text("Codepet") + .font(CodepetTheme.pixel(16)) + .foregroundColor(CodepetTheme.primaryText) + } + .buttonStyle(.plain) + + Spacer(minLength: 8) + + Button { collapsed.toggle() } label: { + Image(systemName: "sidebar.left") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(CodepetTheme.mutedText) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 14).padding(.top, 14).padding(.bottom, 6) + } + + // MARK: - New chat + + private var newChatButton: some View { + Button { + companyStore.newChat() + companyStore.select(.chat) + } label: { + HStack(spacing: 6) { + Image(systemName: "plus").font(.system(size: 11, weight: .semibold)) + Text(lang == .vi ? "Đoạn chat mới" : "New chat") + .font(CodepetTheme.inter(13, weight: .semibold)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(newChatGradient) + .opacity(isChatBusy ? 0.5 : 1.0) + ) + } + .buttonStyle(.plain) + .disabled(isChatBusy) + .padding(.horizontal, 12).padding(.bottom, 12) + } + + // MARK: - Recent (optimized History) + + private struct ThreadBucket: Identifiable { + let id: String + let label: String + let threads: [ChatThread] + } + + /// `sortThreadsByRecent` newest-first, then grouped into Today / Yesterday / + /// Earlier by comparing each thread's `updatedAt` day against now. + private var groupedThreads: [ThreadBucket] { + let sorted = sortThreadsByRecent(companyStore.threads) + let cal = Calendar.current + var today: [ChatThread] = [] + var yesterday: [ChatThread] = [] + var earlier: [ChatThread] = [] + for t in sorted { + if cal.isDateInToday(t.updatedAt) { today.append(t) } + else if cal.isDateInYesterday(t.updatedAt) { yesterday.append(t) } + else { earlier.append(t) } + } + var buckets: [ThreadBucket] = [] + if !today.isEmpty { + buckets.append(ThreadBucket(id: "today", label: lang == .vi ? "Hôm nay" : "Today", threads: today)) + } + if !yesterday.isEmpty { + buckets.append(ThreadBucket(id: "yesterday", label: lang == .vi ? "Hôm qua" : "Yesterday", threads: yesterday)) + } + if !earlier.isEmpty { + buckets.append(ThreadBucket(id: "earlier", label: lang == .vi ? "Trước đó" : "Earlier", threads: earlier)) + } + return buckets + } + + private var recentSection: some View { + VStack(alignment: .leading, spacing: 6) { + sectionLabel(lang == .vi ? "Gần đây" : "Recent") + if groupedThreads.isEmpty { + Text(lang == .vi ? "Chưa có đoạn chat nào." : "No chats yet.") + .font(CodepetTheme.inter(11)) + .foregroundColor(CodepetTheme.mutedText) + .padding(.horizontal, 14) + .padding(.bottom, 4) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(groupedThreads) { bucket in + VStack(alignment: .leading, spacing: 2) { + Text(bucket.label.uppercased()) + .font(CodepetTheme.inter(9, weight: .medium)) + .foregroundColor(CodepetTheme.mutedText.opacity(0.75)) + .padding(.horizontal, 14) + .padding(.bottom, 2) + ForEach(bucket.threads) { threadRow($0) } + } + } + } + .padding(.bottom, 4) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { + recentContentHeight = $0 + } + } + // Hug the content until it outgrows the cap, then scroll — a + // plain `maxHeight` would reserve all 260pt for two threads. + .frame(height: min(recentContentHeight, Self.recentMaxHeight)) + } + } + } + + private func threadRow(_ thread: ChatThread) -> some View { + let isActive = thread.id == companyStore.activeThreadId + return Group { + if renamingId == thread.id { + HStack(spacing: 6) { + TextField(lang == .vi ? "Đổi tên đoạn chat" : "Rename chat", text: $renameDraft) + .textFieldStyle(.plain) + .font(CodepetTheme.inter(12)) + .onSubmit { commitRename(thread.id) } + Button(lang == .vi ? "Lưu" : "Save") { commitRename(thread.id) } + .buttonStyle(.plain) + .font(CodepetTheme.inter(11, weight: .semibold)) + .foregroundColor(CodepetTheme.accentPurple) + } + .padding(.horizontal, 10).padding(.vertical, 7) + } else { + HStack(spacing: 4) { + Button { + companyStore.switchThread(thread.id) + companyStore.select(.chat) + } label: { + VStack(alignment: .leading, spacing: 1) { + Text(thread.title ?? (lang == .vi ? "Đoạn chat mới" : "New chat")) + .font(CodepetTheme.inter(12, weight: isActive ? .semibold : .regular)) + .foregroundColor(CodepetTheme.primaryText) + .lineLimit(1) + Text(relativeTime(thread.updatedAt, now: now)) + .font(CodepetTheme.inter(10)) + .foregroundColor(CodepetTheme.mutedText.opacity(0.85)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isChatBusy) + + Menu { + Button { + renameDraft = thread.title ?? "" + renamingId = thread.id + } label: { + Label(lang == .vi ? "Đổi tên" : "Rename", systemImage: "pencil") + } + Button(role: .destructive) { + companyStore.deleteThread(thread.id) + } label: { + Label(lang == .vi ? "Xóa" : "Delete", systemImage: "trash") + } + .disabled(isChatBusy) + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 11)) + .foregroundColor(CodepetTheme.mutedText) + } + // .button + .plain (not .borderlessButton) so macOS doesn't + // append a system disclosure chevron next to the ellipsis — + // same fix as the composer menus (df83ef8). + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .frame(width: 18) + } + .padding(.horizontal, 10).padding(.vertical, 7) + } + } + .background(RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(isActive ? CodepetTheme.accentPurple.opacity(0.1) : Color.clear)) + .padding(.horizontal, 8) + } + + private func commitRename(_ id: String) { + companyStore.renameThread(id, title: renameDraft) + renamingId = nil + } + + // MARK: - Workspace (was the top-bar tabs) + + private var workspaceSection: some View { + VStack(alignment: .leading, spacing: 2) { + sectionLabel(lang == .vi ? "Không gian" : "Workspace") + .padding(.bottom, 4) + ForEach(AppView.navTabs) { v in workspaceRow(v) } + } + } + + private func workspaceRow(_ v: AppView) -> some View { + let on = companyStore.view == v + let count = tabCount(v) + return Button { + companyStore.selectedDeptKey = nil + companyStore.select(v) + } label: { + HStack(spacing: 10) { + Image(systemName: v.icon) + .font(.system(size: 13)) + .frame(width: 16) + .foregroundColor(on ? CodepetTheme.accentPurple : CodepetTheme.mutedText) + Text(v.title(lang)) + .font(CodepetTheme.inter(13, weight: on ? .semibold : .regular)) + .foregroundColor(on ? CodepetTheme.primaryText : CodepetTheme.bodyText) + Spacer(minLength: 8) + if count > 0 { + Text("\(count)") + .font(CodepetTheme.inter(10, weight: .semibold)) + .foregroundColor(CodepetTheme.onAccent(CodepetTheme.accentGold)) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Capsule().fill(CodepetTheme.accentGold)) + } + } + .padding(.horizontal, 10).padding(.vertical, 7) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(on ? CodepetTheme.accentPurple.opacity(0.08) : Color.clear)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + } + + private func tabCount(_ v: AppView) -> Int { + switch v { + case .tasks: return TopbarCounts.tasks(companyStore.company.tasks) + case .library: return TopbarCounts.library(companyStore.company.library) + case .environment: return TopbarCounts.envPending(enabled: companyStore.company.enabledTools) + default: return 0 + } + } + + // MARK: - Bottom: Upgrade card + account row + + private var bottomSection: some View { + VStack(alignment: .leading, spacing: 10) { + upgradeCard + HStack(spacing: 8) { + AccountMenuView() + Spacer(minLength: 4) + wakeButton + } + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 14) + } + + private var upgradeCard: some View { + Button { companyStore.select(.billing) } label: { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(lang == .vi ? "Nâng cấp Pro" : "Upgrade to Pro") + .font(CodepetTheme.inter(13, weight: .semibold)) + .foregroundColor(CodepetTheme.primaryText) + Text(lang == .vi ? "Thêm credit, mọi phòng ban." : "More credits, all departments.") + .font(CodepetTheme.inter(11)) + .foregroundColor(CodepetTheme.mutedText) + } + Text(lang == .vi ? "Nâng cấp" : "Upgrade") + .font(CodepetTheme.inter(12, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 7) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(newChatGradient)) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(CodepetTheme.surface) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(CodepetTheme.hairline)) + ) + } + .buttonStyle(.plain) + } + + private var wakeButton: some View { + Button { companyStore.select(.environment) } label: { + Text("⚡") + .font(.system(size: 12)) + .frame(width: 26, height: 26) + .background(Circle().fill(CodepetTheme.accentOrange.opacity(0.12))) + } + .buttonStyle(.plain) + } + + // MARK: - Shared + + private func sectionLabel(_ text: String) -> some View { + Text(text.uppercased()) + .font(CodepetTheme.inter(10, weight: .semibold)) + .foregroundColor(CodepetTheme.mutedText) + .padding(.horizontal, 14) + } +} + +#if DEBUG +private struct SidebarPreviewHost: View { + @State private var collapsed = false + var body: some View { + SidebarView(collapsed: $collapsed) + .environmentObject(CompanyStore()) + .environmentObject(AppState()) + .environmentObject(AuthManager()) + .frame(height: 760) + } +} + +#Preview("SidebarView") { SidebarPreviewHost() } +#endif diff --git a/codepet/Views/SplashView.swift b/codepet/Views/SplashView.swift index feb6de9..47f746d 100644 --- a/codepet/Views/SplashView.swift +++ b/codepet/Views/SplashView.swift @@ -7,22 +7,30 @@ import SwiftUI struct SplashView: View { var onContinue: (() -> Void)? = nil - @State private var appear = false @State private var kenBurns = false + @State private var glow = false @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// `.splash-sub` is per-word on the web (`.w` spans) so each word can rise on its + /// own delay; split here so the stagger has something to stagger. + private static let subtitleWords = "Let's learn how to run your company with AI." + .split(separator: " ").map(String.init) + var body: some View { ZStack { OnboardingContent.Palette.coldBg.ignoresSafeArea() - // Slow Ken-Burns image layer. + // Slow Ken-Burns image layer — scale AND drift (`.splash:before`, 30s). GeometryReader { geo in + let drift = OnboardingMotion.kenBurnsDrift(width: geo.size.width, + height: geo.size.height) Image("splash") .resizable() .interpolation(.high) .scaledToFill() .frame(width: geo.size.width, height: geo.size.height) - .scaleEffect(kenBurns ? 1.08 : 1.0) + .scaleEffect(kenBurns ? OnboardingMotion.kenBurnsScale : 1.0) + .offset(x: kenBurns ? drift.width : 0, y: kenBurns ? drift.height : 0) .clipped() } .ignoresSafeArea() @@ -36,17 +44,8 @@ struct SplashView: View { VStack(spacing: 0) { Spacer() - Text("Codepet") - .font(CodepetTheme.pixel(80)) - .tracking(2) - .foregroundColor(.white) - .shadow(color: Color(hex: "#a078ff").opacity(0.55), radius: 17) - .shadow(color: Color(hex: "#220e40").opacity(0.7), radius: 0, x: 0, y: 3) - Text("Let's learn how to run your company with AI.") - .font(CodepetTheme.body(20)) - .foregroundColor(.white) - .padding(.top, 20) - .shadow(color: Color(hex: "#0a041e").opacity(0.7), radius: 9) + title + subtitle if onContinue != nil { Button { onContinue?() } label: { Text("Let's go") @@ -61,6 +60,7 @@ struct SplashView: View { } .buttonStyle(.plain) .padding(.top, 32) + .riseIn(OnboardingMotion.riseSplash, delay: OnboardingMotion.splashButtonDelay) } else { // Auth / cloud still resolving — passive loading affordance, NOT the // interactive CTA (there's no onContinue to fire, so a "Let's go" @@ -73,20 +73,54 @@ struct SplashView: View { Spacer() Text(onContinue != nil ? "click anywhere to continue" : "Loading…") .font(CodepetTheme.body(11)) - .foregroundColor(.white.opacity(0.6)) + .foregroundColor(.white) + .hintPulse() .padding(.bottom, 22) + .riseIn(OnboardingMotion.riseSplash, delay: OnboardingMotion.splashHintDelay) } - .opacity(appear ? 1 : 0) - .offset(y: appear ? 0 : 12) } .contentShape(Rectangle()) .onTapGesture { onContinue?() } .onAppear { - withAnimation(.easeOut(duration: 0.85)) { appear = true } - if !reduceMotion { - withAnimation(.easeInOut(duration: 30).repeatForever(autoreverses: true)) { kenBurns = true } + guard !reduceMotion else { return } + let ken = Animation.easeInOut(duration: OnboardingMotion.kenBurnsSplash) + .repeatForever(autoreverses: true) + withAnimation(ken) { kenBurns = true } + let g = Animation.easeInOut(duration: OnboardingMotion.glowPeriod / 2) + .repeatForever(autoreverses: true) + .delay(OnboardingMotion.glowDelay) + withAnimation(g) { glow = true } + } + } + + /// `.splash-title` — riseIn, then an infinite glow pulse, with `titleSweep` over it. + private var title: some View { + let glowOpacity = glow ? OnboardingMotion.glowOpacityHigh : OnboardingMotion.glowOpacityLow + let glowRadius = glow ? OnboardingMotion.glowRadiusHigh : OnboardingMotion.glowRadiusLow + let text = Text("Codepet") + .font(CodepetTheme.pixel(80)) + .tracking(2) + .foregroundColor(.white) + return text + .shadow(color: Color(hex: "#a078ff").opacity(glowOpacity), radius: glowRadius) + .shadow(color: Color(hex: "#220e40").opacity(0.7), radius: 0, x: 0, y: 3) + .overlay(TitleSweep(mask: text)) + .riseIn(OnboardingMotion.riseSplash, delay: OnboardingMotion.splashTitleDelay) + } + + /// `.splash-sub` — each word rises on its own delay (`.32s + i × 60ms`). + private var subtitle: some View { + HStack(spacing: 6) { + ForEach(Array(Self.subtitleWords.enumerated()), id: \.offset) { i, word in + Text(word) + .font(CodepetTheme.body(20)) + .foregroundColor(.white) + .fixedSize() + .riseIn(OnboardingMotion.riseWord, delay: OnboardingMotion.wordDelay(i)) } } + .padding(.top, 20) + .shadow(color: Color(hex: "#0a041e").opacity(0.7), radius: 9) } } diff --git a/codepetTests/AgentsWorkingRowTests.swift b/codepetTests/AgentsWorkingRowTests.swift new file mode 100644 index 0000000..adf84fa --- /dev/null +++ b/codepetTests/AgentsWorkingRowTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import codepet + +final class AgentsWorkingRowTests: XCTestCase { + private func makeRun(steps: [ExecStep], + status: AgentRunStatus = .working, + startedAt: Date = Date(timeIntervalSince1970: 0)) -> AgentRun { + AgentRun(companionId: "byte", deptName: "Engineering", + taskTitle: "Build the API", steps: steps, + status: status, startedAt: startedAt) + } + + func testStepCounterCountsDoneOverTotal() { + let r = makeRun(steps: [ExecStep(label: "a", done: true), + ExecStep(label: "b", done: true), + ExecStep(label: "c", done: false)]) + XCTAssertEqual(r.stepCounter, "2/3") + } + + func testStepCounterNoneDone() { + let r = makeRun(steps: [ExecStep(label: "a", done: false), + ExecStep(label: "b", done: false)]) + XCTAssertEqual(r.stepCounter, "0/2") + } + + func testCurrentStepIndexIsFirstNotDone() { + let r = makeRun(steps: [ExecStep(label: "a", done: true), + ExecStep(label: "b", done: false), + ExecStep(label: "c", done: false)]) + XCTAssertEqual(r.currentStepIndex, 1) + } + + func testCurrentStepIndexNilWhenAllDone() { + let r = makeRun(steps: [ExecStep(label: "a", done: true)]) + XCTAssertNil(r.currentStepIndex) + } + + func testElapsedStringFormatsMinutesSeconds() { + let r = makeRun(steps: []) + XCTAssertEqual(r.elapsedString(now: Date(timeIntervalSince1970: 134)), "2:14") + } + + func testElapsedStringPadsSeconds() { + let r = makeRun(steps: []) + XCTAssertEqual(r.elapsedString(now: Date(timeIntervalSince1970: 8)), "0:08") + } + + func testElapsedStringNeverNegative() { + let r = makeRun(steps: [], startedAt: Date(timeIntervalSince1970: 100)) + XCTAssertEqual(r.elapsedString(now: Date(timeIntervalSince1970: 40)), "0:00") + } + + func testStatusLabelExhaustiveEnglish() { + XCTAssertEqual(AgentRunStatus.working.label(.en), "Working") + XCTAssertEqual(AgentRunStatus.reviewing.label(.en), "Reviewing") + XCTAssertEqual(AgentRunStatus.done.label(.en), "Done") + XCTAssertEqual(AgentRunStatus.failed.label(.en), "Failed") + } +} diff --git a/codepetTests/AppViewTests.swift b/codepetTests/AppViewTests.swift index 2939177..063d767 100644 --- a/codepetTests/AppViewTests.swift +++ b/codepetTests/AppViewTests.swift @@ -8,8 +8,8 @@ final class AppViewTests: XCTestCase { "environment", "company", "settings", "billing", "support"]) } - func testRailShowsSevenDestinationsInOrder() { - XCTAssertEqual(AppView.navTabs, [.chat, .roadmap, .secondBrain, .company, .tasks, .library, .environment]) + func testRailShowsDestinationsInOrder() { + XCTAssertEqual(AppView.navTabs, [.roadmap, .secondBrain, .company, .tasks, .library, .environment]) } func testRoadmapNavDestinationResolvesToRoadmapNotOverview() { @@ -25,4 +25,10 @@ final class AppViewTests: XCTestCase { XCTAssertFalse(v.icon.isEmpty) } } + + func testChatIsHomeAndOverviewRetired() { + XCTAssertEqual(AppView.navTabs, [.roadmap, .secondBrain, .company, .tasks, .library, .environment]) + XCTAssertFalse(AppView.navTabs.contains(.chat)) // chat is home, not a tab + XCTAssertEqual(AppView.from(navDestination: "roadmap"), .roadmap) + } } diff --git a/codepetTests/ChatContextFocusTests.swift b/codepetTests/ChatContextFocusTests.swift new file mode 100644 index 0000000..780a014 --- /dev/null +++ b/codepetTests/ChatContextFocusTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import codepet + +final class ChatContextFocusTests: XCTestCase { + private let brief = CompanyBrief() + private let dep = DepartmentCatalog.all.first! // e.g. product/engineering + + func testFocusDirectivePresentWhenSet() { + let out = ChatContext.compose(brief: brief, tasks: [], focusDepartment: dep) + XCTAssertTrue(out.contains("focused on the \(dep.name) department"), + "focus directive should name the department") + XCTAssertTrue(out.contains(dep.focus), "focus directive should include the dept focus line") + } + + func testNoDirectiveWhenNil() { + let out = ChatContext.compose(brief: brief, tasks: [], focusDepartment: nil) + XCTAssertFalse(out.contains("focused on the")) + } + + func testNilBranchEqualsDefaultCompose() { + // Parity: passing focusDepartment nil must equal omitting it (no drift). + let a = ChatContext.compose(brief: brief, tasks: [], focusDepartment: nil) + let b = ChatContext.compose(brief: brief, tasks: []) + XCTAssertEqual(a, b) + } +} diff --git a/codepetTests/ChatLandingStateTests.swift b/codepetTests/ChatLandingStateTests.swift new file mode 100644 index 0000000..0cd61a4 --- /dev/null +++ b/codepetTests/ChatLandingStateTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import codepet + +final class ChatLandingStateTests: XCTestCase { + private func date(hour: Int) -> Date { + var c = DateComponents(); c.year = 2026; c.month = 7; c.day = 28; c.hour = hour; c.minute = 0 + return Calendar.current.date(from: c)! + } + private func company(founder: String? = "Mona", project: String? = "Acme", + tasks: [RoadmapTask] = []) -> CompanyState { + var b = CompanyBrief(); b.founderName = founder; b.projectName = project + var c = CompanyState.empty; c.brief = b; c.tasks = tasks + return c + } + + /// A small fixture spanning: one done task, one who==.you not-done at the + /// EARLIEST phase/position (→ this is the beacon, status .needsYou), one + /// drafted task (→ needsApproval), and a SECOND independent who==.you task + /// (→ needsYou, but not the beacon). This deliberately makes the beacon + /// itself a .needsYou task so `needsYouCount`'s "exclude the beacon's id" + /// clause is actually exercised: without it, needsYouCount would double + /// count the beacon instead of reporting just the other needsYou task. + private func fixtureTasks() -> [RoadmapTask] { + [ + RoadmapTask(id: "t1", title: "Set up repo", detail: "", phase: .find, who: .does, done: true), + RoadmapTask(id: "t2", title: "Pick a name", detail: "", phase: .foundation, who: .you), + RoadmapTask(id: "t3", title: "Draft brand brief", detail: "", phase: .foundation, who: .draft, drafted: true), + RoadmapTask(id: "t4", title: "Write landing copy", detail: "", phase: .build, who: .you), + ] + } + + func testGreetingHourBoundaries() { + XCTAssertTrue(ChatLandingState(company: company(), now: date(hour: 11), language: .en).greeting.hasPrefix("Good morning")) + XCTAssertTrue(ChatLandingState(company: company(), now: date(hour: 12), language: .en).greeting.hasPrefix("Good afternoon")) + XCTAssertTrue(ChatLandingState(company: company(), now: date(hour: 17), language: .en).greeting.hasPrefix("Good afternoon")) + XCTAssertTrue(ChatLandingState(company: company(), now: date(hour: 18), language: .en).greeting.hasPrefix("Good evening")) + } + func testGreetingFounderNameAndFallback() { + XCTAssertEqual(ChatLandingState(company: company(founder: "Mona"), now: date(hour: 9), language: .en).greeting, "Good morning, Mona.") + XCTAssertEqual(ChatLandingState(company: company(founder: " "), now: date(hour: 9), language: .en).greeting, "Good morning, there.") + XCTAssertEqual(ChatLandingState(company: company(founder: nil), now: date(hour: 9), language: .vi).greeting, "Chào buổi sáng, bạn.") + } + func testQuestionUsesProjectWithFallback() { + XCTAssertTrue(ChatLandingState(company: company(project: "Acme"), now: date(hour: 9), language: .en).question.contains("Acme")) + XCTAssertTrue(ChatLandingState(company: company(project: " "), now: date(hour: 9), language: .en).question.contains("Codepet")) + } + func testBeaconCountsAndEmpty() { + XCTAssertTrue(ChatLandingState(company: company(tasks: []), now: date(hour: 9), language: .en).isEmpty) + let tasks = fixtureTasks() + let s = ChatLandingState(company: company(tasks: tasks), now: date(hour: 9), language: .en) + XCTAssertEqual(s.beacon?.id, RoadmapEngine.nextStep(tasks)?.id) + // The beacon itself must be a .needsYou task, otherwise the exclusion + // clause below is never actually exercised. + XCTAssertEqual(RoadmapEngine.status(for: s.beacon!, in: tasks), .needsYou) + XCTAssertEqual(s.awaitingApprovalCount, tasks.filter { RoadmapEngine.status(for: $0, in: tasks) == .needsApproval }.count) + let expectedNeedsYouExcludingBeacon = tasks.filter { + RoadmapEngine.status(for: $0, in: tasks) == .needsYou && $0.id != s.beacon?.id + }.count + XCTAssertGreaterThanOrEqual(expectedNeedsYouExcludingBeacon, 1) + XCTAssertEqual(s.needsYouCount, expectedNeedsYouExcludingBeacon) + XCTAssertFalse(s.isEmpty) + } +} diff --git a/codepetTests/ChatModeTests.swift b/codepetTests/ChatModeTests.swift new file mode 100644 index 0000000..225bde3 --- /dev/null +++ b/codepetTests/ChatModeTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import codepet + +final class ChatModeTests: XCTestCase { + func testAskReturnsTextUnchanged() { + XCTAssertEqual(ChatMode.ask.shape("what's next?", language: .en), "what's next?") + XCTAssertEqual(ChatMode.ask.shape("việc gì tiếp?", language: .vi), "việc gì tiếp?") + } + + func testPlanWrapsAndPreservesText() { + let out = ChatMode.plan.shape("pricing page", language: .en) + XCTAssertTrue(out.contains("pricing page")) + XCTAssertNotEqual(out, "pricing page") + XCTAssertTrue(out.lowercased().contains("plan")) + } + + func testBuildWrapsAndPreservesText() { + let out = ChatMode.build.shape("landing page", language: .en) + XCTAssertTrue(out.contains("landing page")) + XCTAssertNotEqual(out, "landing page") + } + + func testPlanIsLocalized() { + XCTAssertNotEqual(ChatMode.plan.shape("x", language: .en), + ChatMode.plan.shape("x", language: .vi)) + } + + func testAllCasesHaveNonEmptyLabels() { + for m in ChatMode.allCases { + XCTAssertFalse(m.label(.en).isEmpty) + XCTAssertFalse(m.label(.vi).isEmpty) + } + } +} diff --git a/codepetTests/ChatPersistenceCodableTests.swift b/codepetTests/ChatPersistenceCodableTests.swift new file mode 100644 index 0000000..448072e --- /dev/null +++ b/codepetTests/ChatPersistenceCodableTests.swift @@ -0,0 +1,56 @@ +// codepetTests/ChatPersistenceCodableTests.swift +import XCTest +@testable import codepet + +/// Chat persistence — CopilotMessage/ChatThread must survive a JSON round-trip +/// (they're written to companies/{uid}/threads/{id}), and a thread's persistable +/// projection must drop transient run state. +final class ChatPersistenceCodableTests: XCTestCase { + private func roundTrip(_ value: T) throws -> T { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(T.self, from: data) + } + + func testPlainMessageRoundTrips() throws { + let m = CopilotMessage(role: .me, text: "hello") + XCTAssertEqual(try roundTrip(m), m) + } + + func testRichMessagesRoundTrip() throws { + let draft = Deliverable(id: "d1", kind: DeliverableKind(raw: "plan"), title: "Plan", + body: "# Body", createdAt: "2026-07-28T00:00:00Z", sourceTaskId: "t1", payload: nil) + let cases: [CopilotMessage] = [ + CopilotMessage(role: .companion, text: "reply", companionId: "nova", deptName: "Marketing"), + CopilotMessage(role: .companion, text: "", draft: draft, draftApproved: true), + CopilotMessage(role: .companion, text: "q", interview: .goal, interviewAnswered: false), + CopilotMessage(role: .companion, text: "", navChip: NavAction(destination: "roadmap", target: nil)), + CopilotMessage(role: .companion, text: "", setupSuggestion: SetupAction(category: "eng", name: "GitHub")), + CopilotMessage(role: .companion, text: "", noted: [RememberedFact(topic: "t", statement: "s")]), + CopilotMessage(role: .companion, text: "greet", firstRunAction: FirstRunAction(taskId: "t1", taskTitle: "T")), + ] + for m in cases { XCTAssertEqual(try roundTrip(m), m) } + } + + func testThreadRoundTrips() throws { + let t = ChatThread(id: "th1", title: "My chat", + messages: [CopilotMessage(role: .me, text: "hi"), + CopilotMessage(role: .companion, text: "hey")], + createdAt: Date(timeIntervalSince1970: 1_000_000), + updatedAt: Date(timeIntervalSince1970: 1_000_100)) + XCTAssertEqual(try roundTrip(t), t) + } + + func testPersistableStripsTransientRunState() { + let steps = [ExecStep(label: "Reading brief", done: true)] + let t = ChatThread(id: "th1", title: nil, messages: [ + CopilotMessage(role: .me, text: "run it"), + CopilotMessage(role: .companion, text: "producing", producing: true, execSteps: steps), + CopilotMessage(role: .companion, text: "done", execSteps: steps), + ], createdAt: Date(timeIntervalSince1970: 0), updatedAt: Date(timeIntervalSince1970: 1)) + let p = t.persistable + XCTAssertEqual(p.messages.count, 2) // producing dropped + XCTAssertFalse(p.messages.contains { $0.producing }) + XCTAssertTrue(p.messages.allSatisfy { $0.execSteps == nil }) // exec state cleared + XCTAssertEqual(p.messages.map(\.text), ["run it", "done"]) + } +} diff --git a/codepetTests/ChatThinkingLabelTests.swift b/codepetTests/ChatThinkingLabelTests.swift new file mode 100644 index 0000000..13570b4 --- /dev/null +++ b/codepetTests/ChatThinkingLabelTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import codepet + +final class ChatThinkingLabelTests: XCTestCase { + func testNoTaskEnglish() { + XCTAssertEqual(ChatThinkingLabel.text(taskTitle: nil, language: .en), "Working on it…") + } + func testNoTaskVietnamese() { + XCTAssertEqual(ChatThinkingLabel.text(taskTitle: nil, language: .vi), "Đang xử lý…") + } + func testNamedTaskEnglish() { + XCTAssertEqual(ChatThinkingLabel.text(taskTitle: "positioning brief", language: .en), + "Drafting positioning brief…") + } + func testNamedTaskVietnamese() { + XCTAssertEqual(ChatThinkingLabel.text(taskTitle: "positioning brief", language: .vi), + "Đang soạn positioning brief…") + } + func testBlankTitleTreatedAsNone() { + XCTAssertEqual(ChatThinkingLabel.text(taskTitle: " ", language: .en), "Working on it…") + } +} diff --git a/codepetTests/ChatThreadsTests.swift b/codepetTests/ChatThreadsTests.swift index da90a91..4a8c788 100644 --- a/codepetTests/ChatThreadsTests.swift +++ b/codepetTests/ChatThreadsTests.swift @@ -33,9 +33,20 @@ final class ChatThreadsTests: XCTestCase { String(repeating: "a", count: 40) + "\u{2026}") } - func testDeriveThreadTitleNilWhenNoMeMessage() { + func testDeriveThreadTitleNilWhenEmpty() { XCTAssertNil(deriveThreadTitle([])) - XCTAssertNil(deriveThreadTitle([companion("hi there")])) + } + + /// A thread with only byte's seeded question/greeting (no founder message yet) + /// now falls back to that message so it gets a distinguishable name, not "New chat". + func testDeriveThreadTitleFallsBackToCompanionWhenNoMe() { + XCTAssertEqual(deriveThreadTitle([companion("hi there")]), "hi there") + } + + /// The founder's first message still wins over an earlier companion message. + func testDeriveThreadTitlePrefersMeOverCompanion() { + XCTAssertEqual(deriveThreadTitle([companion("byte's question"), me("my real topic")]), + "my real topic") } func testDeriveThreadTitleNilWhenFirstMeMessageIsBlank() { @@ -72,6 +83,58 @@ final class ChatThreadsTests: XCTestCase { XCTAssertNil(pickFallbackThreadId(after: "a", in: threads)) } + // MARK: - pickResumeThreadId + + private func thread(_ id: String, _ updatedAt: Date, messages: [CopilotMessage]) -> ChatThread { + ChatThread(id: id, title: id, messages: messages, createdAt: updatedAt, updatedAt: updatedAt) + } + + func testPickResumeThreadIdReturnsMostRecentInsideTheWindow() { + let now = Date(timeIntervalSince1970: 1_000_000) + let threads = [thread("a", now.addingTimeInterval(-3600), messages: [me("older")]), + thread("b", now.addingTimeInterval(-600), messages: [me("newest")]), + thread("c", now.addingTimeInterval(-1800), messages: [me("middle")])] + XCTAssertEqual(pickResumeThreadId(in: threads, now: now), "b") + } + + func testPickResumeThreadIdNilWhenTheLastThreadIsStale() { + let now = Date(timeIntervalSince1970: 1_000_000) + // Quit last evening, reopening the next morning → land on the hero, not + // yesterday's transcript. + let threads = [thread("a", now.addingTimeInterval(-15 * 3600), messages: [me("yesterday")])] + XCTAssertNil(pickResumeThreadId(in: threads, now: now)) + } + + func testPickResumeThreadIdWindowBoundaryIsInclusive() { + let now = Date(timeIntervalSince1970: 1_000_000) + let atEdge = [thread("a", now.addingTimeInterval(-threadResumeWindow), messages: [me("hi")])] + XCTAssertEqual(pickResumeThreadId(in: atEdge, now: now), "a") + let pastEdge = [thread("a", now.addingTimeInterval(-threadResumeWindow - 1), messages: [me("hi")])] + XCTAssertNil(pickResumeThreadId(in: pastEdge, now: now)) + } + + func testPickResumeThreadIdNilWhenNoThreads() { + XCTAssertNil(pickResumeThreadId(in: [], now: Date())) + } + + /// A decoded doc with no messages left (nothing but stripped run state) is not + /// something to resume INTO — fall through to the newest thread that has + /// content, so launch never opens a blank transcript with no hero either. + func testPickResumeThreadIdSkipsMessagelessThreads() { + let now = Date(timeIntervalSince1970: 1_000_000) + let threads = [thread("empty", now.addingTimeInterval(-60), messages: []), + thread("real", now.addingTimeInterval(-300), messages: [me("hi")])] + XCTAssertEqual(pickResumeThreadId(in: threads, now: now), "real") + } + + /// Clock skew (a thread stamped slightly in the future by another device) + /// must not read as "stale" and drop the founder onto the hero. + func testPickResumeThreadIdToleratesFutureTimestamps() { + let now = Date(timeIntervalSince1970: 1_000_000) + let threads = [thread("a", now.addingTimeInterval(120), messages: [me("hi")])] + XCTAssertEqual(pickResumeThreadId(in: threads, now: now), "a") + } + // MARK: - relativeTime func testRelativeTimeFormatsRecentTimes() { diff --git a/codepetTests/CompanyStoreChatRunTests.swift b/codepetTests/CompanyStoreChatRunTests.swift index a94344a..f63e23e 100644 --- a/codepetTests/CompanyStoreChatRunTests.swift +++ b/codepetTests/CompanyStoreChatRunTests.swift @@ -4,6 +4,12 @@ import XCTest @MainActor final class CompanyStoreChatRunTests: XCTestCase { + override func setUp() { + super.setUp() + // Keep the execute-log reveal instant under test (no real-time pacing). + CompanyStore.execStepNanos = 0 + CompanyStore.execDoneBeatNanos = 0 + } private func seeded() -> CompanyState { CompanyState(brief: CompanyBrief(), departments: [], library: [], stage: .idea, companionId: "byte", onboardedAt: Date(), diff --git a/codepetTests/CompanyStoreDeptCompanionTests.swift b/codepetTests/CompanyStoreDeptCompanionTests.swift new file mode 100644 index 0000000..e19ef37 --- /dev/null +++ b/codepetTests/CompanyStoreDeptCompanionTests.swift @@ -0,0 +1,74 @@ +// codepetTests/CompanyStoreDeptCompanionTests.swift +import XCTest +@testable import codepet + +/// #3 — department → companion handoff: a department in focus (chip or a named +/// mention, or a dept-owned task run) brings in the mapped specialist — a host +/// handoff line + the reply/producing/draft attributed to that specialist. +@MainActor +final class CompanyStoreDeptCompanionTests: XCTestCase { + private func seeded(companionId: String = "byte") -> CompanyState { + CompanyState(brief: CompanyBrief(), departments: [], library: [], stage: .idea, + companionId: companionId, onboardedAt: Date(), + tasks: [RoadmapTask(id: "t1", title: "Write landing copy", detail: "hero + bullets", + phase: .build, who: .draft, dept: "mkt")]) + } + /// A streamer that yields one delta + a plain `.done` (no action) — the reply + /// text lands in the placeholder; no run/nav/setup fires. + private static func plainStreamer(_ leadIn: String) + -> (CompanyChatRequest) -> AsyncThrowingStream { + { _ in + AsyncThrowingStream { c in + c.yield(.delta(leadIn)) + c.yield(.done(model: "m", cacheHit: false, action: ChatDoneAction())) + c.finish() + } + } + } + private func store(_ companionId: String = "byte") -> CompanyStore { + CompanyStore(loader: { _ in self.seeded(companionId: companionId) }, saver: { _, _ in true }, + chatSender: { _ in nil }, chatStreamer: Self.plainStreamer("Here's the plan"), + taskRunner: { _ in nil }, decisionExtractor: { _, _ in [] }) + } + + func testDeptChipInsertsHandoffAndAttributesReply() async { + let s = store() + await s.hydrate(companyId: "u") + let mkt = DepartmentCatalog.find("mkt") + await s.sendChat("what next?", language: .en, department: mkt) + // me, handoff (host), reply (specialist) + XCTAssertEqual(s.chatMessages.map(\.role), [.me, .companion, .companion]) + let handoff = s.chatMessages[1] + XCTAssertNil(handoff.companionId) // host speaks the handoff + XCTAssertTrue(handoff.text.contains("Nova")) // mkt → nova + let reply = s.chatMessages[2] + XCTAssertEqual(reply.companionId, "nova") + XCTAssertEqual(reply.deptName, "Marketing") + XCTAssertEqual(reply.text, "Here's the plan") + } + + func testTextMentionTriggersSpecialistWithoutChip() async { + let s = store() + await s.hydrate(companyId: "u") + await s.sendChat("help me with marketing", language: .en) // no chip, name mentioned + XCTAssertEqual(s.chatMessages.last?.companionId, "nova") + XCTAssertEqual(s.chatMessages.last?.deptName, "Marketing") + } + + func testNoDeptNoHandoff() async { + let s = store() + await s.hydrate(companyId: "u") + await s.sendChat("just a general question", language: .en) + XCTAssertEqual(s.chatMessages.map(\.role), [.me, .companion]) // no handoff inserted + XCTAssertNil(s.chatMessages.last?.companionId) + } + + func testSpecialistEqualToHostIsNoOp() async { + // Host is already nova → focusing marketing shouldn't insert a handoff. + let s = store("nova") + await s.hydrate(companyId: "u") + await s.sendChat("marketing please", language: .en) + XCTAssertEqual(s.chatMessages.map(\.role), [.me, .companion]) + XCTAssertNil(s.chatMessages.last?.companionId) + } +} diff --git a/codepetTests/CompanyStoreExecLogTests.swift b/codepetTests/CompanyStoreExecLogTests.swift new file mode 100644 index 0000000..87a44ad --- /dev/null +++ b/codepetTests/CompanyStoreExecLogTests.swift @@ -0,0 +1,45 @@ +// codepetTests/CompanyStoreExecLogTests.swift +import XCTest +@testable import codepet + +/// #2 — the run execute-log: the steps describe the real pipeline (context → +/// [specialist] → draft → review) and are grounded in the actual request inputs. +@MainActor +final class CompanyStoreExecLogTests: XCTestCase { + private func task() -> RoadmapTask { + RoadmapTask(id: "t1", title: "Write landing copy", detail: "hero", phase: .build, who: .draft, dept: "mkt") + } + + func testStepsWithSpecialistAndDecisions() { + let steps = CompanyStore.execSteps(task: task(), + specialist: ("nova", "Marketing"), + decisionCount: 2, language: .en).map(\.label) + XCTAssertEqual(steps, [ + "Reading your brief — mission, audience, your voice (+ 2 decisions)", + "Pulling in the Marketing playbook", + "Drafting Write landing copy", + "Matching your tone and past decisions", + ]) + } + + func testStepsWithoutSpecialistOrDecisions() { + let steps = CompanyStore.execSteps(task: task(), + specialist: nil, + decisionCount: 0, language: .en).map(\.label) + XCTAssertEqual(steps, [ + "Reading your brief — mission, audience, your voice", + "Drafting Write landing copy", + "Matching your tone and past decisions", + ]) + // Every step starts not-done. + let raw = CompanyStore.execSteps(task: task(), specialist: nil, decisionCount: 0, language: .en) + XCTAssertTrue(raw.allSatisfy { !$0.done }) + } + + func testVietnameseLabels() { + let steps = CompanyStore.execSteps(task: task(), specialist: ("nova", "Marketing"), + decisionCount: 1, language: .vi).map(\.label) + XCTAssertTrue(steps.first?.hasPrefix("Đọc brief") ?? false) + XCTAssertEqual(steps.last, "Khớp giọng điệu và quyết định của bạn") + } +} diff --git a/codepetTests/CompanyStoreFanOutTests.swift b/codepetTests/CompanyStoreFanOutTests.swift new file mode 100644 index 0000000..fcd8faf --- /dev/null +++ b/codepetTests/CompanyStoreFanOutTests.swift @@ -0,0 +1,136 @@ +// codepetTests/CompanyStoreFanOutTests.swift +import XCTest +@testable import codepet + +/// Full-flow test for the parallel department-agent fan-out: the client planner +/// picks next moves, `fanOutNextMoves` seeds `activeAgentRuns` and dispatches +/// concurrent runs, each agent's draft lands in the transcript, and the busy +/// flags clear — plus the honest empty-plan / failure paths and account-guard. +@MainActor +final class CompanyStoreFanOutTests: XCTestCase { + private var savedStepNanos: UInt64 = 0 + private var savedBeatNanos: UInt64 = 0 + + override func setUp() { + super.setUp() + // Collapse the client-side step-reveal + done-beat sleeps so the flow runs fast. + savedStepNanos = CompanyStore.execStepNanos + savedBeatNanos = CompanyStore.execDoneBeatNanos + CompanyStore.execStepNanos = 1_000 + CompanyStore.execDoneBeatNanos = 1_000 + } + override func tearDown() { + CompanyStore.execStepNanos = savedStepNanos + CompanyStore.execDoneBeatNanos = savedBeatNanos + super.tearDown() + } + + /// A codepetCanDo task (who: .does, not done/drafted, no deps) in `dept`. + private func task(_ id: String, dept: String, phase: RoadmapPhase = .build) -> RoadmapTask { + RoadmapTask(id: id, title: "Task \(id)", detail: "d", phase: phase, who: .does, dept: dept) + } + + /// A store seeded (via the loader) with `tasks` and the given stub `taskRunner`. + /// Firebase-touching savers/loaders are stubbed so the test bundle never hits a + /// live Firestore (unconfigured FirebaseApp would crash). + private func store(tasks: [RoadmapTask], + runner: @escaping (RunTaskRequest) async -> RunTaskResponse?) -> CompanyStore { + let seed = CompanyState(brief: .init(), departments: [], library: [], stage: .building, + companionId: "byte", onboardedAt: Date(), tasks: tasks) + return CompanyStore(loader: { _ in seed }, + tasksSaver: { _, _ in true }, + taskRunner: runner, + librarySaver: { _, _ in true }, + threadSaver: { _, _ in true }, + threadsLoader: { _ in [] }) + } + + private func draftCount(_ s: CompanyStore) -> Int { + s.chatMessages.filter { $0.draft != nil }.count + } + private func honestBubbleCount(_ s: CompanyStore) -> Int { + s.chatMessages.filter { $0.role == .companion && $0.draft == nil && !$0.text.isEmpty }.count + } + + // MARK: - Happy path + + func testFanOutSeedsRunsAppendsDraftPerAgentAndClears() async { + let s = store(tasks: [task("e1", dept: "eng"), task("d1", dept: "design"), task("m1", dept: "mkt")], + runner: { req in RunTaskResponse(kind: "doc", title: "Out \(req.taskId)", body: "# body") }) + await s.hydrate(companyId: "u") + await s.fanOutNextMoves(language: .en) + + XCTAssertEqual(draftCount(s), 3) // one draft per agent landed + XCTAssertTrue(s.activeAgentRuns.isEmpty) // the live row cleared when all finished + XCTAssertFalse(s.isFanningOut) // busy flag cleared → composer re-enabled + let sources = Set(s.chatMessages.compactMap { $0.draft?.sourceTaskId }) + XCTAssertEqual(sources, ["e1", "d1", "m1"]) // drafts trace back to the planned tasks + } + + func testCapLimitsToMaxFanOutAgents() async { + let s = store(tasks: [task("e1", dept: "eng"), task("d1", dept: "design"), + task("m1", dept: "mkt"), task("o1", dept: "ops")], + runner: { _ in RunTaskResponse(kind: "doc", title: "x", body: "# b") }) + await s.hydrate(companyId: "u") + await s.fanOutNextMoves(language: .en) + + XCTAssertEqual(CompanyStore.maxFanOut, 3) + XCTAssertEqual(draftCount(s), 3) // 4 eligible depts, capped at maxFanOut + } + + // MARK: - Honest paths + + func testEmptyPlanShowsHonestBubbleAndNoBusyState() async { + // Only a needsYou task → nothing codepetCanDo → empty plan. + let s = store(tasks: [RoadmapTask(id: "y1", title: "You do it", detail: "", phase: .build, + who: .you, dept: "eng")], + runner: { _ in RunTaskResponse(kind: "doc", title: "x", body: "# b") }) + await s.hydrate(companyId: "u") + await s.fanOutNextMoves(language: .en) + + XCTAssertEqual(draftCount(s), 0) + XCTAssertEqual(honestBubbleCount(s), 1) // "you're all caught up" bubble + XCTAssertFalse(s.isFanningOut) + XCTAssertTrue(s.activeAgentRuns.isEmpty) + } + + func testFailedRunShowsFailedBubbleNoDraft() async { + let s = store(tasks: [task("e1", dept: "eng")], runner: { _ in nil }) // nil → failure + await s.hydrate(companyId: "u") + await s.fanOutNextMoves(language: .en) + + XCTAssertEqual(draftCount(s), 0) // no draft on failure + XCTAssertEqual(honestBubbleCount(s), 1) // honest "couldn't finish" bubble + XCTAssertTrue(s.activeAgentRuns.isEmpty) + XCTAssertFalse(s.isFanningOut) + } + + // MARK: - Guards + + func testAccountSwitchDuringFanOutDiscardsDraft() async { + var ref: CompanyStore? + let s = store(tasks: [task("e1", dept: "eng")], + runner: { _ in + await ref?.hydrate(companyId: "B") // switch account mid-run + return RunTaskResponse(kind: "doc", title: "x", body: "# b") + }) + ref = s + await s.hydrate(companyId: "A") + await s.fanOutNextMoves(language: .en) + + XCTAssertEqual(draftCount(s), 0) // draft discarded — never lands in another account + XCTAssertFalse(s.isFanningOut) // account-switch cleared the flag + XCTAssertTrue(s.activeAgentRuns.isEmpty) + } + + func testResetClearsFanOutState() async { + let s = store(tasks: [task("e1", dept: "eng")], + runner: { _ in RunTaskResponse(kind: "doc", title: "x", body: "# b") }) + await s.hydrate(companyId: "u") + await s.fanOutNextMoves(language: .en) + s.reset() + + XCTAssertTrue(s.activeAgentRuns.isEmpty) + XCTAssertFalse(s.isFanningOut) + } +} diff --git a/codepetTests/CompanyStoreFirstRunGreetingTests.swift b/codepetTests/CompanyStoreFirstRunGreetingTests.swift index 998f27c..3258997 100644 --- a/codepetTests/CompanyStoreFirstRunGreetingTests.swift +++ b/codepetTests/CompanyStoreFirstRunGreetingTests.swift @@ -3,6 +3,12 @@ import XCTest @MainActor final class CompanyStoreFirstRunGreetingTests: XCTestCase { + override func setUp() { + super.setUp() + // "Do it with me" now runs through the execute-log path; keep it instant. + CompanyStore.execStepNanos = 0 + CompanyStore.execDoneBeatNanos = 0 + } private func seeded(tasks: [RoadmapTask], brief: CompanyBrief) -> CompanyState { CompanyState(brief: brief, departments: [], library: [], stage: .idea, companionId: "byte", onboardedAt: nil, tasks: tasks) diff --git a/codepetTests/CompanyStorePersistenceTests.swift b/codepetTests/CompanyStorePersistenceTests.swift new file mode 100644 index 0000000..186cebf --- /dev/null +++ b/codepetTests/CompanyStorePersistenceTests.swift @@ -0,0 +1,229 @@ +// codepetTests/CompanyStorePersistenceTests.swift +import XCTest +@testable import codepet + +/// Chat persistence wiring in CompanyStore: hydrate loads the Recent list (opening +/// to the hero), a sent turn persists its thread, delete removes it — all via the +/// injected fail-soft seam (no Firestore). +@MainActor +final class CompanyStorePersistenceTests: XCTestCase { + /// Spy for the thread persistence seam. + private final class Spy { + var saved: [ChatThread] = [] + var deleted: [String] = [] + var toLoad: [ChatThread] = [] + } + + private func seeded() -> CompanyState { + CompanyState(brief: CompanyBrief(), departments: [], library: [], stage: .idea, + companionId: "byte", onboardedAt: Date(), tasks: []) + } + private static let plainStreamer: (CompanyChatRequest) -> AsyncThrowingStream = { _ in + AsyncThrowingStream { c in + c.yield(.delta("reply")); c.yield(.done(model: "m", cacheHit: false, action: ChatDoneAction())); c.finish() + } + } + private func store(_ spy: Spy) -> CompanyStore { + CompanyStore(loader: { _ in self.seeded() }, saver: { _, _ in true }, + chatSender: { _ in nil }, chatStreamer: Self.plainStreamer, + taskRunner: { _ in nil }, decisionExtractor: { _, _ in [] }, + threadSaver: { _, t in spy.saved.append(t); return true }, + threadDeleter: { _, id in spy.deleted.append(id); return true }, + threadsLoader: { _ in spy.toLoad }) + } + /// Let fire-and-forget @MainActor Tasks (flush/delete persistence) run. + private func drain() async { for _ in 0..<10 { await Task.yield() } } + + /// Stale history (last touched well outside the resume window) hydrates the + /// Recent list but still opens the hero — history lives in the switcher. + func testHydrateLoadsRecentAndOpensHeroWhenHistoryIsStale() async { + let spy = Spy() + spy.toLoad = [ + ChatThread(id: "a", title: "First", messages: [], createdAt: Date(timeIntervalSince1970: 1), updatedAt: Date(timeIntervalSince1970: 2)), + ChatThread(id: "b", title: "Second", messages: [], createdAt: Date(timeIntervalSince1970: 3), updatedAt: Date(timeIntervalSince1970: 4)), + ] + let s = store(spy) + await s.hydrate(companyId: "u") + XCTAssertEqual(s.threads.map(\.id), ["a", "b"]) // Recent list hydrated + XCTAssertNil(s.activeThreadId) // opens to the hero + XCTAssertTrue(s.chatMessages.isEmpty) + } + + /// Launch inside the resume window reopens the newest thread with its + /// messages already in the working buffer. + func testHydrateResumesRecentThread() async { + let spy = Spy() + let now = Date() + spy.toLoad = [ + ChatThread(id: "old", title: "Older", messages: [CopilotMessage(role: .me, text: "older ask")], + createdAt: now.addingTimeInterval(-7200), updatedAt: now.addingTimeInterval(-3600)), + ChatThread(id: "recent", title: "Recent", messages: [CopilotMessage(role: .me, text: "where we left off")], + createdAt: now.addingTimeInterval(-1800), updatedAt: now.addingTimeInterval(-300)), + ] + let s = store(spy) + await s.hydrate(companyId: "u") + XCTAssertEqual(s.activeThreadId, "recent") + XCTAssertEqual(s.chatMessages.map(\.text), ["where we left off"]) + XCTAssertEqual(s.threads.count, 2) // full Recent list still hydrated + } + + /// Resuming must not re-persist the thread it just opened (hydrate is a read). + func testHydrateResumeDoesNotRewriteTheThread() async { + let spy = Spy() + let now = Date() + spy.toLoad = [ChatThread(id: "recent", title: "Recent", messages: [CopilotMessage(role: .me, text: "hi")], + createdAt: now.addingTimeInterval(-600), updatedAt: now.addingTimeInterval(-60))] + let s = store(spy) + await s.hydrate(companyId: "u") + await drain() + XCTAssertTrue(spy.saved.isEmpty) + } + + /// Resuming, then sending, appends to the SAME thread rather than forking a + /// second one — the resumed id stays the active buffer's identity. + func testSendAfterResumeAppendsToSameThread() async { + let spy = Spy() + let now = Date() + spy.toLoad = [ChatThread(id: "recent", title: "Recent", messages: [CopilotMessage(role: .me, text: "first")], + createdAt: now.addingTimeInterval(-600), updatedAt: now.addingTimeInterval(-60))] + let s = store(spy) + await s.hydrate(companyId: "u") + await s.sendChat("second", language: .en) + await drain() + XCTAssertEqual(s.activeThreadId, "recent") + XCTAssertEqual(s.threads.count, 1) + XCTAssertTrue(s.chatMessages.map(\.text).contains("first")) + XCTAssertTrue(s.chatMessages.map(\.text).contains("second")) + XCTAssertEqual(spy.saved.last?.id, "recent") + } + + // MARK: - The DEBUG resume-window override + + /// Only a positive override counts. A stray key reading 0 must NOT be taken + /// literally as "resume nothing, ever". + func testResumeWindowHonoursOnlyAPositiveOverride() { + let key = "CODEPET_RESUME_WINDOW_SECONDS" + let defaults = UserDefaults.standard + defer { defaults.removeObject(forKey: key) } + + defaults.removeObject(forKey: key) + XCTAssertEqual(CompanyStore.resumeWindow, threadResumeWindow) + + defaults.set(10.0, forKey: key) + XCTAssertEqual(CompanyStore.resumeWindow, 10) + + defaults.set(0.0, forKey: key) + XCTAssertEqual(CompanyStore.resumeWindow, threadResumeWindow) + + defaults.set(-5.0, forKey: key) + XCTAssertEqual(CompanyStore.resumeWindow, threadResumeWindow) + } + + /// The override is what `hydrate` actually resumes against — a thread just + /// outside a tiny window opens the hero. + func testHydrateRespectsTheOverriddenWindow() async { + let key = "CODEPET_RESUME_WINDOW_SECONDS" + UserDefaults.standard.set(2.0, forKey: key) + defer { UserDefaults.standard.removeObject(forKey: key) } + + let spy = Spy() + let now = Date() + spy.toLoad = [ChatThread(id: "a", title: "Recent", messages: [CopilotMessage(role: .me, text: "hi")], + createdAt: now.addingTimeInterval(-120), updatedAt: now.addingTimeInterval(-30))] + let s = store(spy) + await s.hydrate(companyId: "u") + XCTAssertNil(s.activeThreadId, "30s old is stale under a 2s window") + XCTAssertTrue(s.chatMessages.isEmpty) + XCTAssertEqual(s.threads.count, 1, "still hydrated into Recent") + } + + // MARK: - Resuming a thread that stopped mid enrichment interview + + /// A persisted thread whose last message is byte's unanswered `gap` question. + private func interviewThread(gap: InterviewGap, answeredBefore: [InterviewGap] = []) -> ChatThread { + let now = Date() + var messages: [CopilotMessage] = answeredBefore.map { + CopilotMessage(role: .companion, text: "asked \($0)", interview: $0, interviewAnswered: true) + } + messages.append(CopilotMessage(role: .companion, text: "what's your main goal?", interview: gap)) + return ChatThread(id: "mid-interview", title: nil, messages: messages, + createdAt: now.addingTimeInterval(-600), updatedAt: now.addingTimeInterval(-60)) + } + + /// The relaunch case: `interviewState` is session-only and gone, so answering + /// the resumed question must still save AND ask the next gap — not go silent. + func testAnsweringResumedInterviewQuestionAsksTheNextGap() async { + let spy = Spy() + spy.toLoad = [interviewThread(gap: .goal)] + let s = store(spy) + await s.hydrate(companyId: "u") + guard let pending = s.pendingInterview else { return XCTFail("expected the resumed question to be pending") } + XCTAssertEqual(pending.gap, .goal) + + await s.answerInterview(messageId: pending.id, gap: .goal, answer: "Ship v1", language: .en) + XCTAssertEqual(s.company.brief.goal, "Ship v1") + XCTAssertEqual(s.chatMessages.last?.interview, .traction) // chain continued + } + + /// Skipping (blank answer) after a relaunch must not re-ask the skipped gap — + /// a skip saves nothing, so only the transcript remembers it happened. + func testSkippingResumedInterviewQuestionDoesNotReAskIt() async { + let spy = Spy() + spy.toLoad = [interviewThread(gap: .goal)] + let s = store(spy) + await s.hydrate(companyId: "u") + guard let pending = s.pendingInterview else { return XCTFail("expected a pending question") } + + await s.answerInterview(messageId: pending.id, gap: .goal, answer: " ", language: .en) + XCTAssertNil(s.company.brief.goal) // skip saves nothing + XCTAssertEqual(s.chatMessages.last?.interview, .traction) // moved on, not re-asked + XCTAssertEqual(s.chatMessages.filter { $0.interview == .goal }.count, 1) + } + + /// Answering the LAST outstanding gap after a relaunch hands off to the + /// first-run greeting, exactly as a normally-completed interview does. + func testAnsweringFinalResumedGapSeedsTheGreeting() async { + let spy = Spy() + spy.toLoad = [interviewThread(gap: .problem, answeredBefore: [.goal, .traction])] + let filled = CompanyState(brief: CompanyBrief(goal: "Ship v1", traction: "None yet"), + departments: [], library: [], stage: .idea, + companionId: "byte", onboardedAt: Date(), tasks: []) + let s = CompanyStore(loader: { _ in filled }, saver: { _, _ in true }, + chatSender: { _ in nil }, chatStreamer: Self.plainStreamer, + taskRunner: { _ in nil }, decisionExtractor: { _, _ in [] }, + threadSaver: { _, t in spy.saved.append(t); return true }, + threadDeleter: { _, id in spy.deleted.append(id); return true }, + threadsLoader: { _ in spy.toLoad }) + await s.hydrate(companyId: "u") + guard let pending = s.pendingInterview else { return XCTFail("expected a pending question") } + XCTAssertEqual(pending.gap, .problem) + + await s.answerInterview(messageId: pending.id, gap: .problem, answer: "Founders lose context", language: .en) + guard let last = s.chatMessages.last else { return XCTFail("no messages") } + XCTAssertEqual(last.role, .companion) + XCTAssertNil(last.interview, "the interview is over — this should be the greeting") + XCTAssertNil(s.pendingInterview) + } + + func testSentTurnPersistsThread() async { + let spy = Spy() + let s = store(spy) + await s.hydrate(companyId: "u") + await s.sendChat("hi there", language: .en) + await drain() + XCTAssertFalse(spy.saved.isEmpty) // thread was persisted + XCTAssertTrue(spy.saved.last?.messages.contains { $0.text == "hi there" } ?? false) + } + + func testDeletePersistsRemoval() async { + let spy = Spy() + let s = store(spy) + await s.hydrate(companyId: "u") + await s.sendChat("hello", language: .en) + await drain() + guard let id = s.activeThreadId else { return XCTFail("no active thread") } + s.deleteThread(id) + await drain() + XCTAssertTrue(spy.deleted.contains(id)) + } +} diff --git a/codepetTests/EnrichInterviewTests.swift b/codepetTests/EnrichInterviewTests.swift index f4d0042..86f4ead 100644 --- a/codepetTests/EnrichInterviewTests.swift +++ b/codepetTests/EnrichInterviewTests.swift @@ -22,6 +22,24 @@ final class EnrichInterviewTests: XCTestCase { func testNeverMoreThanMaxQuestions() { XCTAssertLessThanOrEqual(EnrichInterview.detectGaps(CompanyBrief()).count, EnrichInterview.maxQuestions) } + // MARK: - remainingGaps (relaunch, no in-memory cursor) + + func testRemainingGapsExcludesWhatTheTranscriptAlreadyAnswered() { + // goal was answered (and saved), traction was SKIPPED — so the brief still + // reads traction as empty, but it must not be asked again. + let b = CompanyBrief(goal: "Ship v1") + XCTAssertEqual(EnrichInterview.remainingGaps(b, answered: [.goal, .traction]), [.problem]) + } + + func testRemainingGapsMatchesDetectGapsWhenNothingAnswered() { + XCTAssertEqual(EnrichInterview.remainingGaps(CompanyBrief(), answered: []), + EnrichInterview.detectGaps(CompanyBrief())) + } + + func testRemainingGapsEmptyWhenEveryGapWasAskedAndSkipped() { + XCTAssertTrue(EnrichInterview.remainingGaps(CompanyBrief(), answered: [.goal, .traction, .problem]).isEmpty) + } + func testEnglishGoalQuestion() { let q = EnrichInterview.question(for: .goal, language: .en) XCTAssertEqual(q.ask, "What\u{2019}s your main goal for the next few weeks?") diff --git a/codepetTests/MessageCardStyleTests.swift b/codepetTests/MessageCardStyleTests.swift new file mode 100644 index 0000000..85d670e --- /dev/null +++ b/codepetTests/MessageCardStyleTests.swift @@ -0,0 +1,98 @@ +import XCTest +import SwiftUI +@testable import codepet + +final class MessageCardStyleTests: XCTestCase { + private let sentinel = Color.pink // stand-in companion accent + + func testHueTable() { + XCTAssertEqual(MessageCardStyle.hue(for: .draft, companionAccent: sentinel), CodepetTheme.accentGold) + XCTAssertEqual(MessageCardStyle.hue(for: .interview, companionAccent: sentinel), CodepetTheme.accentBlue) + XCTAssertEqual(MessageCardStyle.hue(for: .setupSuggestion, companionAccent: sentinel), CodepetTheme.accentTeal) + XCTAssertEqual(MessageCardStyle.hue(for: .noted, companionAccent: sentinel), CodepetTheme.mutedText) + XCTAssertEqual(MessageCardStyle.hue(for: .navChip, companionAccent: sentinel), CodepetTheme.hairline) + // firstRunAction returns the companion accent verbatim + XCTAssertEqual(MessageCardStyle.hue(for: .firstRunAction, companionAccent: sentinel), sentinel) + } + + func testKindNilForPlainText() { + let m = CopilotMessage(role: .companion, text: "hello") + XCTAssertNil(MessageCardStyle.kind(for: m)) + } + + func testKindNilForProducing() { + let m = CopilotMessage(role: .companion, text: "", producing: true) + XCTAssertNil(MessageCardStyle.kind(for: m)) + } + + func testKindPerPayload() { + // Construct minimal valid instances of each payload type by reading their + // initializers (Deliverable, InterviewGap, SetupAction, FirstRunAction, + // RememberedFact, NavAction). Set them via CopilotMessage's init args. + // draft: + XCTAssertEqual(MessageCardStyle.kind(for: msg(draft: minimalDraft())), .draft) + // interview: + XCTAssertEqual(MessageCardStyle.kind(for: msg(interview: minimalGap())), .interview) + // setupSuggestion: + XCTAssertEqual(MessageCardStyle.kind(for: msg(setupSuggestion: minimalSetup())), .setupSuggestion) + // firstRunAction: + XCTAssertEqual(MessageCardStyle.kind(for: msg(firstRunAction: minimalAction())), .firstRunAction) + // noted: + XCTAssertEqual(MessageCardStyle.kind(for: msg(noted: [minimalFact()])), .noted) + // navChip: + XCTAssertEqual(MessageCardStyle.kind(for: msg(navChip: minimalNav())), .navChip) + } + + func testPrecedenceDraftBeatsInterview() { + let m = msg(draft: minimalDraft(), interview: minimalGap()) + XCTAssertEqual(MessageCardStyle.kind(for: m), .draft) + } + + // MARK: - Helpers + + /// Thin wrapper over CopilotMessage.init — only the payload args under test + /// vary; role/text are fixed placeholders. + private func msg( + draft: Deliverable? = nil, + interview: InterviewGap? = nil, + setupSuggestion: SetupAction? = nil, + firstRunAction: FirstRunAction? = nil, + noted: [RememberedFact]? = nil, + navChip: NavAction? = nil + ) -> CopilotMessage { + CopilotMessage( + role: .companion, text: "", + draft: draft, firstRunAction: firstRunAction, interview: interview, + navChip: navChip, setupSuggestion: setupSuggestion, noted: noted) + } + + // Deliverable(id: String = UUID(), kind:, title:, body:, createdAt: nil, + // sourceTaskId: nil, payload: nil) — codepet/Models/Deliverable.swift. + private func minimalDraft() -> Deliverable { + Deliverable(kind: .doc, title: "t", body: "b") + } + + // InterviewGap is a plain enum { goal, traction, problem } — + // codepet/Models/EnrichInterview.swift. No init needed. + private func minimalGap() -> InterviewGap { .goal } + + // SetupAction(category:, name:) — codepet/Services/CompanyChatClient.swift. + private func minimalSetup() -> SetupAction { + SetupAction(category: "c", name: "n") + } + + // FirstRunAction(taskId:, taskTitle:) — codepet/Models/FirstRunGreeting.swift. + private func minimalAction() -> FirstRunAction { + FirstRunAction(taskId: "id", taskTitle: "title") + } + + // RememberedFact(topic:, statement:) — codepet/Services/CompanyChatClient.swift. + private func minimalFact() -> RememberedFact { + RememberedFact(topic: "topic", statement: "statement") + } + + // NavAction(destination:, target:) — codepet/Services/CompanyChatClient.swift. + private func minimalNav() -> NavAction { + NavAction(destination: "d", target: nil) + } +} diff --git a/codepetTests/MessageFeedbackTests.swift b/codepetTests/MessageFeedbackTests.swift new file mode 100644 index 0000000..e7cc3ef --- /dev/null +++ b/codepetTests/MessageFeedbackTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import codepet + +final class MessageFeedbackTests: XCTestCase { + private func fixture(helpful: Bool) -> MessageFeedback { + MessageFeedback(messageId: "m123", helpful: helpful, companyId: "c1", + userId: "u1", companionId: "byte") + } + + func testDataHelpfulTrue() { + let d = fixture(helpful: true).firestoreData() + XCTAssertEqual(d["kind"] as? String, "chat_message") + XCTAssertEqual(d["messageId"] as? String, "m123") + XCTAssertEqual(d["helpful"] as? Bool, true) + XCTAssertEqual(d["companyId"] as? String, "c1") + XCTAssertEqual(d["userId"] as? String, "u1") + XCTAssertEqual(d["companionId"] as? String, "byte") + XCTAssertEqual(d["platform"] as? String, "macos") + } + + func testDataHelpfulFalse() { + XCTAssertEqual(fixture(helpful: false).firestoreData()["helpful"] as? Bool, false) + } + + func testNoTimestampKey() { + // The server timestamp is added by the writer, not the payload. + XCTAssertNil(fixture(helpful: true).firestoreData()["timestamp"]) + } +} diff --git a/codepetTests/OnboardingContentTests.swift b/codepetTests/OnboardingContentTests.swift index 3803743..740b6fb 100644 --- a/codepetTests/OnboardingContentTests.swift +++ b/codepetTests/OnboardingContentTests.swift @@ -15,10 +15,27 @@ final class OnboardingContentTests: XCTestCase { XCTAssertEqual(OnboardingContent.departments.count, 8) XCTAssertEqual(OnboardingContent.departments.first?.name, "Engineering") XCTAssertEqual(OnboardingContent.analysisLines.count, 4) - XCTAssertEqual(OnboardingContent.total, 9) - // step art covers steps 0...8 (9 entries) - XCTAssertEqual(OnboardingContent.stepArt.count, 9) + // 8, not the web's 9 — the companion-picker step is cut (see OnboardingView). + XCTAssertEqual(OnboardingContent.total, 8) + // step art covers steps 0...7 (8 entries) + XCTAssertEqual(OnboardingContent.stepArt.count, 8) XCTAssertEqual(OnboardingContent.stepArt[0], "ob-team") XCTAssertEqual(OnboardingContent.stepArt[6], "ob-boardroom") } + + /// The art and grade arrays are indexed by step, so a mismatch would either crash + /// or silently clamp the last screens onto the wrong image — which is exactly what + /// a step removal is prone to leaving behind. + func testStepArtAndGradeCoverEveryStepExactly() { + XCTAssertEqual(OnboardingContent.stepArt.count, OnboardingContent.total) + XCTAssertEqual(OnboardingContent.stepGrade.count, OnboardingContent.total) + } + + /// The reveal (step 7) is now the last screen, so its footer must read "Step 8 of 8" + /// — no dangling step beyond the finish button. + func testTheLastStepIsTheReveal() { + let lastStepIndex = OnboardingContent.total - 1 + XCTAssertEqual(lastStepIndex, 7) + XCTAssertEqual(OnboardingContent.stepArt[lastStepIndex], "ob-team") + } } diff --git a/codepetTests/OnboardingLayoutTests.swift b/codepetTests/OnboardingLayoutTests.swift new file mode 100644 index 0000000..d3ca154 --- /dev/null +++ b/codepetTests/OnboardingLayoutTests.swift @@ -0,0 +1,105 @@ +// codepetTests/OnboardingLayoutTests.swift +import XCTest +@testable import codepet + +/// The onboarding chrome is sized fluidly on the web (`.ob-art { width: 42% }`, +/// `clamp()` on the cold-open headline and inset). The first native port froze those +/// at their ~860pt design-width values, so the flow degraded on a full-screen window. +/// These pin the proportional behaviour AND the clamps at both extremes. +final class OnboardingLayoutTests: XCTestCase { + + // MARK: art panel — web `.ob-art { width: 42% }` + + func testArtPanelIsFortyTwoPercentInTheNormalRange() { + // 42% exactly, wherever the clamps don't bite. + XCTAssertEqual(OnboardingLayout.artWidth(container: 860), 361.2, accuracy: 0.01) + XCTAssertEqual(OnboardingLayout.artWidth(container: 1200), 504, accuracy: 0.01) + XCTAssertEqual(OnboardingLayout.artWidth(container: 1400), 588, accuracy: 0.01) + } + + func testArtPanelKeepsIts42PercentShareOnWideDisplays() { + // No upper clamp — the web's `.ob-art` is a straight 42% at any width. An + // earlier 620pt ceiling reduced this to 24% at 2560pt, recreating the collapsed + // composition the original fixed 360pt width caused. + XCTAssertEqual(OnboardingLayout.artWidth(container: 1512), 635.04, accuracy: 0.01) + XCTAssertEqual(OnboardingLayout.artWidth(container: 2560), 1075.2, accuracy: 0.01) + XCTAssertEqual(OnboardingLayout.artWidth(container: 5120), 2150.4, accuracy: 0.01) + } + + func testArtPanelStaysAProportionNotACeiling() { + // The ratio must hold across the whole realistic range, not just at one width. + for w in stride(from: CGFloat(800), through: 3200, by: 100) { + let ratio = OnboardingLayout.artWidth(container: w) / w + XCTAssertEqual(ratio, 0.42, accuracy: 0.001, "art panel drifted off 42% at \(w)pt") + } + } + + /// 600pt is a max-width, not a fixed width — below ~1256pt the column compresses, + /// exactly as the web's `.ob-body { width: 100%; max-width: 600px }` does. + /// Threshold: w − 0.42w − 128 ≥ 600 ⇒ w ≥ 1255.17. + private func formRoom(_ w: CGFloat) -> CGFloat { + w - OnboardingLayout.artWidth(container: w) - 128 + } + + func testFullMeasureFitsOnceTheWindowIsFullScreenSized() { + for w in stride(from: CGFloat(1280), through: 3200, by: 100) { + XCTAssertGreaterThanOrEqual(formRoom(w), 600, "form measure squeezed at \(w)pt") + } + } + + func testMeasureCompressesGracefullyBelowThatThresholdRatherThanBreaking() { + // Still positive and usable everywhere down to the 560pt minimum window — + // the regression being guarded against is a negative width, not a narrow one. + for w in stride(from: CGFloat(560), to: 1280, by: 20) { + XCTAssertGreaterThan(formRoom(w), 0, "form panel collapsed at \(w)pt") + } + XCTAssertLessThan(formRoom(1200), 600) // documents the compression, 568pt + XCTAssertGreaterThan(formRoom(1260), 600) // just above the threshold + } + + func testArtPanelClampsOnNarrowWindowsSoTheFormStillFits() { + // 42% of 560 is 235pt; the lower clamp keeps the art readable at 320. + XCTAssertEqual(OnboardingLayout.artWidth(container: 560), 320, accuracy: 0.01) + XCTAssertEqual(OnboardingLayout.artWidth(container: 200), 320, accuracy: 0.01) + XCTAssertEqual(OnboardingLayout.artWidth(container: 0), 320, accuracy: 0.01) + } + + func testArtPanelNeverStarvesTheFormAtTheMinimumWindowWidth() { + // The regression that motivated raising minWidth 400 → 560: art + the form's + // 2×64pt padding must leave positive room for the step column. + let minWindow: CGFloat = 560 + let formPadding: CGFloat = 64 * 2 + let remaining = minWindow - OnboardingLayout.artWidth(container: minWindow) - formPadding + XCTAssertGreaterThan(remaining, 0, "form panel would be driven to a negative width") + } + + // MARK: cold open — web `clamp(34px, 4vw, 52px)` / `clamp(40px, 9vw, 150px)` + + func testColdHeadlineScalesWithWidthThenClamps() { + XCTAssertEqual(OnboardingLayout.coldHeadline(container: 860), 34.4, accuracy: 0.01) // 4vw + XCTAssertEqual(OnboardingLayout.coldHeadline(container: 1100), 44, accuracy: 0.01) // 4vw + XCTAssertEqual(OnboardingLayout.coldHeadline(container: 1512), 52, accuracy: 0.01) // upper clamp + XCTAssertEqual(OnboardingLayout.coldHeadline(container: 2560), 52, accuracy: 0.01) // upper clamp + XCTAssertEqual(OnboardingLayout.coldHeadline(container: 600), 34, accuracy: 0.01) // lower clamp + } + + func testColdLeadingScalesWithWidthThenClamps() { + XCTAssertEqual(OnboardingLayout.coldLeading(container: 860), 77.4, accuracy: 0.01) // 9vw + XCTAssertEqual(OnboardingLayout.coldLeading(container: 1512), 136.08, accuracy: 0.01) // 9vw + XCTAssertEqual(OnboardingLayout.coldLeading(container: 2560), 150, accuracy: 0.01) // upper clamp + XCTAssertEqual(OnboardingLayout.coldLeading(container: 300), 40, accuracy: 0.01) // lower clamp + } + + func testHelpersAreMonotonicSoTheLayoutNeverJumpsBackwards() { + var lastArt: CGFloat = 0, lastHead: CGFloat = 0, lastLead: CGFloat = 0 + for w in stride(from: CGFloat(200), through: 3000, by: 50) { + let art = OnboardingLayout.artWidth(container: w) + let head = OnboardingLayout.coldHeadline(container: w) + let lead = OnboardingLayout.coldLeading(container: w) + XCTAssertGreaterThanOrEqual(art, lastArt, "art width regressed at \(w)") + XCTAssertGreaterThanOrEqual(head, lastHead, "headline regressed at \(w)") + XCTAssertGreaterThanOrEqual(lead, lastLead, "leading regressed at \(w)") + lastArt = art; lastHead = head; lastLead = lead + } + } +} diff --git a/codepetTests/OnboardingMotionTests.swift b/codepetTests/OnboardingMotionTests.swift new file mode 100644 index 0000000..776ab3e --- /dev/null +++ b/codepetTests/OnboardingMotionTests.swift @@ -0,0 +1,127 @@ +// codepetTests/OnboardingMotionTests.swift +import XCTest +import SwiftUI +@testable import codepet + +/// The motion layer is a 1:1 port of the web's keyframes, so these lock the timings +/// against the stylesheet. If someone retunes a duration here, it should be a +/// deliberate divergence from `app.css`, not a drift nobody noticed. +final class OnboardingMotionTests: XCTestCase { + + // MARK: riseIn timings — `.splash-*`, `.ob-cold-in > *`, `.ob-body > *` + + func testRiseDurationsMatchTheWeb() { + XCTAssertEqual(OnboardingMotion.riseSplash, 0.9) // .splash-title/-btn/-hint + XCTAssertEqual(OnboardingMotion.riseWord, 0.7) // .splash-sub .w + XCTAssertEqual(OnboardingMotion.riseCold, 0.85) // .ob-cold-in > * + XCTAssertEqual(OnboardingMotion.riseStep, 0.55) // .ob-body > * + } + + func testEntranceDelaysMatchTheWeb() { + XCTAssertEqual(OnboardingMotion.splashTitleDelay, 0.15) + XCTAssertEqual(OnboardingMotion.splashButtonDelay, 0.48) + XCTAssertEqual(OnboardingMotion.splashHintDelay, 0.66) + XCTAssertEqual(OnboardingMotion.coldHeadlineDelay, 0.10) + XCTAssertEqual(OnboardingMotion.coldParagraphDelay, 0.24) + XCTAssertEqual(OnboardingMotion.coldChipsDelay, 0.50) + XCTAssertEqual(OnboardingMotion.stepHeadingDelay, 0.04) + XCTAssertEqual(OnboardingMotion.stepSubDelay, 0.12) + XCTAssertEqual(OnboardingMotion.stepRestDelay, 0.20) + } + + /// `.splash-sub .w { animation-delay: calc(.32s + var(--i) * 60ms) }` + func testPerWordDelayIsBaseThenSixtyMsSteps() { + XCTAssertEqual(OnboardingMotion.wordDelay(0), 0.32, accuracy: 0.0001) + XCTAssertEqual(OnboardingMotion.wordDelay(1), 0.38, accuracy: 0.0001) + XCTAssertEqual(OnboardingMotion.wordDelay(5), 0.62, accuracy: 0.0001) + } + + func testPerWordDelayIsStrictlyIncreasing() { + for i in 0..<20 { + XCTAssertLessThan(OnboardingMotion.wordDelay(i), OnboardingMotion.wordDelay(i + 1)) + } + } + + /// The whole splash entrance should be over in about a second — a stagger that + /// outlasts the screen would just read as jank. + func testSplashEntranceSettlesQuickly() { + let lastWord = OnboardingMotion.wordDelay(7) + OnboardingMotion.riseWord + let lastChrome = OnboardingMotion.splashHintDelay + OnboardingMotion.riseSplash + XCTAssertLessThan(max(lastWord, lastChrome), 1.75) + } + + // MARK: Ken Burns — `@keyframes kenburns` + + func testKenBurnsPeriodsAndScale() { + XCTAssertEqual(OnboardingMotion.kenBurnsSplash, 30) // .splash:before + XCTAssertEqual(OnboardingMotion.kenBurnsCold, 32) // .ob-cold:after + XCTAssertEqual(OnboardingMotion.kenBurnsScale, 1.08) + } + + /// `translate(-1.2%, -1.6%)` of the layer's own size — up and to the left. + func testKenBurnsDriftIsProportionalAndNegative() { + let d = OnboardingMotion.kenBurnsDrift(width: 1000, height: 500) + XCTAssertEqual(d.width, -12, accuracy: 0.001) + XCTAssertEqual(d.height, -8, accuracy: 0.001) + + let big = OnboardingMotion.kenBurnsDrift(width: 2560, height: 1440) + XCTAssertEqual(big.width, -30.72, accuracy: 0.001) + XCTAssertEqual(big.height, -23.04, accuracy: 0.001) + } + + func testKenBurnsDriftScalesWithTheLayer() { + let small = OnboardingMotion.kenBurnsDrift(width: 800, height: 600) + let large = OnboardingMotion.kenBurnsDrift(width: 1600, height: 1200) + XCTAssertEqual(large.width, small.width * 2, accuracy: 0.001) + XCTAssertEqual(large.height, small.height * 2, accuracy: 0.001) + } + + // MARK: titleSweep — `@keyframes titleSweep` over 4.6s, crossing in the first 55% + + func testSweepCrossPlusHoldIsTheFullCycle() { + XCTAssertEqual(OnboardingMotion.sweepCross + OnboardingMotion.sweepHold, 4.6, accuracy: 0.01) + } + + func testSweepCrossesInFiftyFivePercentOfTheCycle() { + let cycle = OnboardingMotion.sweepCross + OnboardingMotion.sweepHold + XCTAssertEqual(OnboardingMotion.sweepCross / cycle, 0.55, accuracy: 0.005) + } + + func testSweepTravelsFullyOffBothEdges() { + // ±120% so the bar is never parked visibly on the glyphs. + XCTAssertLessThanOrEqual(OnboardingMotion.sweepFrom, -1.0) + XCTAssertGreaterThanOrEqual(OnboardingMotion.sweepTo, 1.0) + } + + // MARK: looping accents + + func testHintPulseRange() { + XCTAssertEqual(OnboardingMotion.hintPeriod, 2.8) + XCTAssertEqual(OnboardingMotion.hintOpacityLow, 0.42) + XCTAssertEqual(OnboardingMotion.hintOpacityHigh, 0.72) + XCTAssertLessThan(OnboardingMotion.hintOpacityLow, OnboardingMotion.hintOpacityHigh) + } + + func testTitleGlowRangeGrowsAndStaysSubtle() { + XCTAssertEqual(OnboardingMotion.glowPeriod, 5) + XCTAssertLessThan(OnboardingMotion.glowRadiusLow, OnboardingMotion.glowRadiusHigh) + XCTAssertLessThan(OnboardingMotion.glowOpacityLow, OnboardingMotion.glowOpacityHigh) + XCTAssertLessThanOrEqual(OnboardingMotion.glowOpacityHigh, 1.0) + } + + // MARK: art panel — `.ob-art span { transition: opacity 1.1s, transform 7s }` + + func testArtCrossfadeIsMuchFasterThanTheScaleSettle() { + XCTAssertEqual(OnboardingMotion.artCrossfade, 1.1) + XCTAssertEqual(OnboardingMotion.artSettle, 7) + // The image must be fully opaque long before it finishes settling, otherwise + // the drift reads as a load glitch rather than a slow push-in. + XCTAssertLessThan(OnboardingMotion.artCrossfade, OnboardingMotion.artSettle / 3) + } + + func testArtEnterScaleOverscansSoNoEdgeShowsDuringTheSettle() { + XCTAssertEqual(OnboardingMotion.artEnterScale, 1.07) + // Must be > 1, or the layer would settle outward and reveal the panel edge. + XCTAssertGreaterThan(OnboardingMotion.artEnterScale, 1.0) + } +} diff --git a/codepetTests/OnboardingStageSliderTests.swift b/codepetTests/OnboardingStageSliderTests.swift index a17ff00..04590de 100644 --- a/codepetTests/OnboardingStageSliderTests.swift +++ b/codepetTests/OnboardingStageSliderTests.swift @@ -16,3 +16,47 @@ final class OnboardingStageSliderTests: XCTestCase { XCTAssertEqual(StageSliderMath.stageIndex(atX: 10, width: 0, count: 6), 0) } } + +/// The track is inset by half a thumb at each end (web `.sb-track { inset: 0 15px }`). +/// Drawing and hit-testing must share that span — if one uses the full width and the +/// other the inset track, the thumb drifts away from the cursor. +final class StageSliderInsetTests: XCTestCase { + private let inset: CGFloat = 15 + private let count = 6 + + func testTrackExcludesBothInsets() { + XCTAssertEqual(StageSliderMath.trackWidth(container: 500, inset: inset), 470) + XCTAssertEqual(StageSliderMath.trackWidth(container: 1000, inset: inset), 970) + } + + func testTrackNeverCollapsesToZeroOnTinyContainers() { + // Guards a divide-by-zero in the frac maths on a mid-resize layout pass. + XCTAssertGreaterThan(StageSliderMath.trackWidth(container: 20, inset: inset), 0) + XCTAssertGreaterThan(StageSliderMath.trackWidth(container: 0, inset: inset), 0) + } + + func testEndpointsSitFullyInsideTheContainer() { + let w: CGFloat = 500 + let first = StageSliderMath.centerX(forIndex: 0, container: w, inset: inset, count: count) + let last = StageSliderMath.centerX(forIndex: count - 1, container: w, inset: inset, count: count) + // A 26pt thumb centred here must not overhang either edge. + XCTAssertGreaterThanOrEqual(first - 13, 0, "thumb clipped at the left edge") + XCTAssertLessThanOrEqual(last + 13, w, "thumb clipped at the right edge") + } + + func testDrawingAndHitTestingAgreeAtEveryStage() { + for w in [CGFloat(400), 500, 900, 1400] { + let track = StageSliderMath.trackWidth(container: w, inset: inset) + for i in 0.. RoadmapTask { + RoadmapTask(id: id, title: id, detail: "", phase: phase, who: who, + dependsOn: dependsOn, done: done, drafted: drafted, dept: dept) + } + + func testPicksFirstCodepetCanDoPerDistinctDeptInRoadmapOrder() { + let tasks = [ + task("m1", dept: "mkt", phase: .launch), + task("e1", dept: "eng", phase: .build), + task("e2", dept: "eng", phase: .build), // same dept as e1 → skipped + task("d1", dept: "design", phase: .foundation), + ] + let picked = RoadmapEngine.nextMoves(tasks, limit: 3).map(\.id) + // Ordered by phase: foundation(d1) < build(e1) < launch(m1); one per dept. + XCTAssertEqual(picked, ["d1", "e1", "m1"]) + } + + func testCapLimitsCount() { + let tasks = [ + task("d1", dept: "design", phase: .foundation), + task("e1", dept: "eng", phase: .build), + task("m1", dept: "mkt", phase: .launch), + task("f1", dept: "fin", phase: .grow), + ] + XCTAssertEqual(RoadmapEngine.nextMoves(tasks, limit: 2).map(\.id), ["d1", "e1"]) + } + + func testSkipsIneligibleTasks() { + let tasks = [ + task("done1", dept: "eng", phase: .build, done: true), // done + task("you1", dept: "design", phase: .build, who: .you), // needsYou + task("draft1", dept: "mkt", phase: .build, drafted: true), // needsApproval + task("blocked1", dept: "fin", phase: .build, dependsOn: ["x"]), // blocked (dep x not done) + task("x", dept: "zzz", phase: .build, done: false), // the missing dep target (no companion mapping) + task("nodept", dept: nil, phase: .build), // no dept + task("nomap", dept: "zzz", phase: .build), // dept has no companion mapping + task("ok", dept: "ops", phase: .build), // the only eligible one + ] + XCTAssertEqual(RoadmapEngine.nextMoves(tasks, limit: 3).map(\.id), ["ok"]) + } + + func testEmptyWhenNothingActionableOrLimitZero() { + let none = [task("you1", dept: "eng", phase: .build, who: .you)] + XCTAssertEqual(RoadmapEngine.nextMoves(none, limit: 3).count, 0) + XCTAssertEqual(RoadmapEngine.nextMoves([], limit: 3).count, 0) + XCTAssertEqual(RoadmapEngine.nextMoves([task("e1", dept: "eng", phase: .build)], limit: 0).count, 0) + } +} diff --git a/docs/superpowers/plans/2026-07-27-chat-as-main-interface.md b/docs/superpowers/plans/2026-07-27-chat-as-main-interface.md new file mode 100644 index 0000000..cd18d72 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-chat-as-main-interface.md @@ -0,0 +1,70 @@ +# Chat as Main Interface — Implementation Plan + +> **For agentic workers:** implement task-by-task; each task ends build-green (and, where noted, test-green). Steps use `- [ ]`. + +**Goal:** Make chat Codepet's full-width default page; remove Overview + the 50% side panel; split Roadmap/Second Brain into two pages; keep onboarding and the top-bar look. + +**Architecture:** Reuse the v2 `CopilotChatView` as a full-width destination; `AppShellView` drops its copilot side panel; `AppView` gains `.chat`/`.secondBrain` and retires `.overview`; Overview's map/panel move into standalone `RoadmapView`/`SecondBrainView`. + +## Global Constraints +- Native macOS SwiftUI; edits under `codepet/Models`, `codepet/Managers`, `codepet/Views`. All colors/spacing via `CodepetTheme`. Bilingual vi/en. Do not restyle the top bar (brand/account/tabs/wake/upgrade) beyond making the wordmark a Home button and changing which tabs appear. +- No backend/`CompanyStore` logic changes except the default `view` value. +- Build/test in the FOREGROUND: `xcodebuild -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO`. Known pre-existing `CompanyStoreScaffordOnboardingTests` Firebase-init flake may crash-retry (fixed on separate PR #40, not on this branch) — treat only those 2 as known; nothing else may newly fail. +- Commit messages end with: `Co-Authored-By: Claude Opus 4.8 (1M context) ` + +--- + +### Task IA-1: Add `RoadmapView` + `SecondBrainView` (extract from Overview; leave nav untouched) +**Files:** Create `codepet/Views/Overview/RoadmapView.swift`, `codepet/Views/Overview/SecondBrainView.swift`. Read `codepet/Views/Overview/OverviewView.swift` (256 lines) to reuse its header pieces. +**Do:** Each new view renders the shared header chrome that OverviewView shows (the "How to read this map" pill, the KEY legend, the Project-Progress card) **minus** the `Roadmap | Second Brain` toggle, then its body: +- `RoadmapView` body → `RoadmapMapView(tasks: companyStore.company.tasks)` (match how OverviewView builds `tasks`). +- `SecondBrainView` body → `SecondBrainPanel(data: SecondBrainData(company: companyStore.company), lang: lang, …)` (match OverviewView's exact call, including any closures it passes). +Factor the shared header into a small private helper or a shared subview to avoid duplication. Do NOT modify OverviewView or nav yet (build stays green with OverviewView still wired). +**Verify:** `xcodebuild build` → BUILD SUCCEEDED. Add a `#Preview` to each. Commit: `feat(nav): extract RoadmapView + SecondBrainView from Overview`. + +--- + +### Task IA-2: Rewire navigation — chat is home, Overview retired, full-width shell +**Files:** `codepet/Models/AppView.swift`, `codepet/Managers/CompanyStore.swift`, `codepet/Views/Shell/AppShellView.swift`; delete `codepet/Views/Overview/OverviewView.swift`. Test: `codepetTests/AppViewTests.swift`. + +- [ ] **AppView.swift** — add cases and retire `.overview`: + - `enum` cases: replace `overview` usage; final set includes `chat`, `summary`, `company`, `roadmap`, `secondBrain`, `tasks`, `library`, `environment`, `settings`, `billing`, `support` (remove `overview`). + - `navTabs = [.summary, .roadmap, .secondBrain, .company, .tasks, .library, .environment]` + - `title(_:)`: add `case .chat: return lang == .vi ? "Trò chuyện" : "Chat"` and `case .secondBrain: return lang == .vi ? "Bộ não thứ hai" : "Second Brain"`; remove the `.overview` case. + - `icon`: add `case .chat: return "message"` and `case .secondBrain: return "brain"`; remove `.overview` (`roadmap` keeps `"map"`). + - `from(navDestination:)`: change `case "roadmap": return .roadmap` (was `.overview`). +- [ ] **CompanyStore.swift** — line 10 `@Published var view: AppView = .chat` (was `.overview`); line ~842 in `reset()` `view = .chat` (was `.overview`). +- [ ] **AppShellView.swift** — full-width content + new branches + Home logo + drop the side panel: + - Delete `@State private var copilotCollapsed`, the `copilot` and `chatToggle` computed vars, and the `.overlay(alignment: .bottomTrailing) { chatToggle … }`. + - `body`'s inner layout becomes just `content.frame(maxWidth: .infinity, maxHeight: .infinity)` (remove the `GeometryReader`/`HStack`/`Divider`/`copilot` split). + - `content`: remove the `.overview → OverviewView()` branch; add `if companyStore.view == .chat { CopilotChatView() }`, `else if .roadmap { RoadmapView() }`, `else if .secondBrain { SecondBrainView() }`. + - Make the wordmark a button: replace the `Text("Codepet")…` in `topBar` with `Button { companyStore.selectedDeptKey = nil; companyStore.select(.chat) } label: { Text("Codepet").font(CodepetTheme.pixel(16)).foregroundColor(CodepetTheme.primaryText) }.buttonStyle(.plain)`. + - Delete `codepet/Views/Overview/OverviewView.swift`. +- [ ] **AppViewTests.swift** — add: + ```swift + func testNavTabsAreTheChatFirstStructure() { + XCTAssertFalse(AppView.navTabs.contains(.overview) == true) // .overview retired + XCTAssertTrue(AppView.navTabs.contains(.roadmap)) + XCTAssertTrue(AppView.navTabs.contains(.secondBrain)) + XCTAssertFalse(AppView.navTabs.contains(.chat)) // chat is home, not a tab + XCTAssertEqual(AppView.from(navDestination: "roadmap"), .roadmap) + } + ``` + (If `.overview` is fully removed from the enum, drop the first assertion — it won't compile — and instead assert `navTabs.first == .summary` and `navTabs == [.summary, .roadmap, .secondBrain, .company, .tasks, .library, .environment]`.) +**Verify:** `xcodebuild build` → SUCCEEDED (no lingering `.overview` refs); full suite green except the known flake. Commit: `feat(nav): chat is the full-width home; retire Overview; Roadmap/Second Brain tabs`. + +--- + +### Task IA-3: Full-width chat column +**Files:** `codepet/Views/Copilot/CopilotChatView.swift`. +**Do:** Constrain the chat content to a centered readable column (~720pt) now that it's full-width instead of a 50% panel: +- Wrap the `messageList` scroll content and the docked `composerView` so their inner content is centered with `.frame(maxWidth: 720)` inside a full-width, centered container (e.g. put the message `VStack` and the composer each in an `HStack { Spacer(minLength: 0); .frame(maxWidth: 720); Spacer(minLength: 0) }`, or a centered `.frame(maxWidth: 720)` with `.frame(maxWidth: .infinity)` parent). The empty-state (`ChatEmptyState`) already centers — leave it, but confirm it looks right full-width. +- Keep all existing behavior (scroll-to-bottom, typing/producing rows, History branch, send/focus). +**Verify:** `xcodebuild build` → SUCCEEDED; full suite green except known flake. Commit: `style(chat): center chat content in a max-width column for full-width`. + +--- + +## Self-Review +- Spec coverage: chat full-width home (IA-2 branch + IA-3 centering) ✓; Overview removed (IA-2) ✓; Roadmap+Second Brain pages (IA-1 + IA-2 branches/tabs) ✓; logo→home (IA-2) ✓; onboarding untouched (no task touches ContentView/OnboardingView) ✓; top bar not restyled (only tab set + logo button) ✓. +- Sequencing keeps build green: IA-1 adds views without touching nav; IA-2 flips nav + deletes Overview in one task; IA-3 is isolated polish. +- Type consistency: `RoadmapView()`/`SecondBrainView()` no-arg views used by AppShellView match IA-1's definitions; `.chat`/`.secondBrain` used across AppView/CompanyStore/AppShellView are all defined in IA-2's AppView edit. diff --git a/docs/superpowers/plans/2026-07-27-native-chat-redesign.md b/docs/superpowers/plans/2026-07-27-native-chat-redesign.md new file mode 100644 index 0000000..1d19885 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-native-chat-redesign.md @@ -0,0 +1,679 @@ +# Native Chat Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rework the native chat column into a centered hero + elevated composer with Ask/Plan/Build modes and capability quick-actions, plus a restyled active conversation — no backend changes. + +**Architecture:** A pure `ChatMode` enum shapes the outgoing message client-side; a reusable `ChatComposer` view is used in both the empty and active states; a `ChatEmptyState` view renders the centered hero + quick-action pills. `CopilotChatView` becomes a thin shell that switches between empty and active layouts. All other chat behavior (send, streaming, threads, draft cards, interview/nav/setup chips) is preserved unchanged. + +**Tech Stack:** SwiftUI (macOS), XCTest, `CodepetTheme` design tokens, Google Sans Flex via `CodepetTheme.inter`. + +## Global Constraints + +- Native macOS SwiftUI app. All edits live under `codepet/Views/Copilot/` and `codepet/Models/`. Tests under `codepetTests/`. +- **No changes** to `CompanyStore`, `CompanyChatClient`, any Cloud Function, or any model other than the new `ChatMode`. +- **No fake affordances:** the composer has NO file-attach button; the `+` button opens a real quick-actions menu; modes are client-side message-shaping only. +- All colors, radii, and spacing come from `CodepetTheme` tokens (never hardcoded hex) so the UI adapts to the app's dynamic light/dark automatically. +- Fonts via `CodepetTheme.inter(_:weight:)`. The hero greeting is Google Sans Flex (NOT the Minecraft pixel font). +- Bilingual: every user-facing string has a `.vi` and `.en` form, following the existing `lang == .vi ? … : …` pattern. `AppLanguage` cases are `.vi` and `.en`. +- Preserve behavior: sending (Return / send button), streaming, thread switching, draft approve/redo, interview send/skip, nav/setup/noted chips must all still work. +- **Build/test commands must run in the FOREGROUND** (backgrounded `xcodebuild` stalls in this environment). Commands: + - Single test class: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO -only-testing:codepetTests/ 2>&1 | tail -30` + - Full suite: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -40` + - Build only: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -30` +- Every commit message ends with: + `Co-Authored-By: Claude Opus 4.8 (1M context) ` + +## File Structure + +| File | Responsibility | Status | +|---|---|---| +| `codepet/Models/ChatMode.swift` | Pure enum `.ask/.plan/.build`; `label(_:)`, `shape(_:language:)` | Create | +| `codepetTests/ChatModeTests.swift` | Unit tests for `ChatMode` | Create | +| `codepet/Views/Copilot/ChatComposer.swift` | Reusable composer (field + `+` menu + mode menu + send) | Create | +| `codepet/Views/Copilot/ChatEmptyState.swift` | Centered hero greeting + quick-action pills | Create | +| `codepet/Views/Copilot/CopilotChatView.swift` | Thin shell; wires composer + empty state; deletes old input/greeting/letsBuild; bubble restyle | Modify | + +--- + +### Task 1: `ChatMode` model + tests + +**Files:** +- Create: `codepet/Models/ChatMode.swift` +- Test: `codepetTests/ChatModeTests.swift` + +**Interfaces:** +- Consumes: `AppLanguage` (cases `.vi`, `.en`). +- Produces: + - `enum ChatMode: CaseIterable, Identifiable { case ask, plan, build }` + - `func label(_ lang: AppLanguage) -> String` + - `func shape(_ text: String, language lang: AppLanguage) -> String` (`.ask` is identity) + +- [ ] **Step 1: Write the failing test** + +Create `codepetTests/ChatModeTests.swift`: + +```swift +import XCTest +@testable import codepet + +final class ChatModeTests: XCTestCase { + func testAskReturnsTextUnchanged() { + XCTAssertEqual(ChatMode.ask.shape("what's next?", language: .en), "what's next?") + XCTAssertEqual(ChatMode.ask.shape("việc gì tiếp?", language: .vi), "việc gì tiếp?") + } + + func testPlanWrapsAndPreservesText() { + let out = ChatMode.plan.shape("pricing page", language: .en) + XCTAssertTrue(out.contains("pricing page")) + XCTAssertNotEqual(out, "pricing page") + XCTAssertTrue(out.lowercased().contains("plan")) + } + + func testBuildWrapsAndPreservesText() { + let out = ChatMode.build.shape("landing page", language: .en) + XCTAssertTrue(out.contains("landing page")) + XCTAssertNotEqual(out, "landing page") + } + + func testPlanIsLocalized() { + XCTAssertNotEqual(ChatMode.plan.shape("x", language: .en), + ChatMode.plan.shape("x", language: .vi)) + } + + func testAllCasesHaveNonEmptyLabels() { + for m in ChatMode.allCases { + XCTAssertFalse(m.label(.en).isEmpty) + XCTAssertFalse(m.label(.vi).isEmpty) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO -only-testing:codepetTests/ChatModeTests 2>&1 | tail -30` +Expected: FAIL — compile error, `ChatMode` is undefined. + +- [ ] **Step 3: Write minimal implementation** + +Create `codepet/Models/ChatMode.swift`: + +```swift +import Foundation + +/// Client-side chat "mode". Shapes the founder's raw message with an intent, +/// then hands the plain string to the existing `CompanyStore.sendChat`. There +/// is no backend concept of modes and no build session — this is pure +/// message-shaping, which is why it is a small, unit-testable value type. +enum ChatMode: CaseIterable, Identifiable { + case ask, plan, build + + var id: Self { self } + + /// Short control label — matches the terse pill style used elsewhere in chat. + func label(_ lang: AppLanguage) -> String { + switch (self, lang) { + case (.ask, .vi): return "Hỏi" + case (.ask, _): return "Ask" + case (.plan, .vi): return "Lập kế hoạch" + case (.plan, _): return "Plan" + case (.build, .vi): return "Bắt tay làm" + case (.build, _): return "Build" + } + } + + /// Wrap the founder's raw text with this mode's intent. `.ask` is identity. + /// `.build` copy is deliberately modest — the chat can already run tasks and + /// produce draft deliverables; it must NOT imply the (not-yet-native) build agent. + func shape(_ text: String, language lang: AppLanguage) -> String { + switch self { + case .ask: + return text + case .plan: + return lang == .vi + ? "Giúp mình lập kế hoạch — nêu các bước cụ thể tiếp theo: \(text)" + : "Help me plan this — give me the concrete next steps: \(text)" + case .build: + return lang == .vi + ? "Cùng bắt tay làm luôn — nếu là việc bạn làm được, hãy chạy và cho mình xem bản nháp: \(text)" + : "Let's build this together — if it's a task you can do, run it and show me a draft: \(text)" + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO -only-testing:codepetTests/ChatModeTests 2>&1 | tail -30` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add codepet/Models/ChatMode.swift codepetTests/ChatModeTests.swift +git commit -m "feat(chat): add ChatMode message-shaping enum + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: `ChatComposer` reusable view + +**Files:** +- Create: `codepet/Views/Copilot/ChatComposer.swift` + +**Interfaces:** +- Consumes: `ChatMode` (Task 1); `CodepetTheme`; `\.uiLanguage` environment. +- Produces: + ```swift + struct ChatComposer: View { + @Binding var draft: String + @Binding var mode: ChatMode + var canSend: Bool + var focus: FocusState.Binding + var placeholder: String + var quickActions: [String] + var onSend: () -> Void + var onQuickAction: (String) -> Void + } + ``` + +This view has no snapshot-test harness in the repo, so its verification is: the +project builds, its `#Preview` renders, and the full suite stays green. Correctness +of the message-shaping it triggers is covered by Task 1. + +- [ ] **Step 1: Write the view + preview** + +Create `codepet/Views/Copilot/ChatComposer.swift`: + +```swift +import SwiftUI + +/// The chat composer — one reusable input surface used in BOTH the empty hero +/// and the docked active conversation. Owns no state: draft/mode live in the +/// parent (`CopilotChatView`) so the same value drives both placements. +/// +/// Honesty notes: the `+` button is a quick-actions menu (NOT a file picker — +/// the app has no attachments), and the mode control shapes the outgoing message +/// via `ChatMode` (no backend mode exists). +struct ChatComposer: View { + @Binding var draft: String + @Binding var mode: ChatMode + var canSend: Bool + var focus: FocusState.Binding + var placeholder: String + var quickActions: [String] + var onSend: () -> Void + var onQuickAction: (String) -> Void + + @Environment(\.uiLanguage) private var lang + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + TextField(placeholder, text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .font(CodepetTheme.inter(15)) + .lineLimit(1...6) + .focused(focus) + .onSubmit(onSend) + + HStack(spacing: 8) { + quickActionsMenu + modeMenu + Spacer() + sendButton + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(CodepetTheme.surface) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(CodepetTheme.hairline) + ) + } + + private var quickActionsMenu: some View { + Menu { + ForEach(quickActions, id: \.self) { qa in + Button(qa) { onQuickAction(qa) } + } + } label: { + Image(systemName: "plus") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(CodepetTheme.bodyText) + .frame(width: 30, height: 30) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(CodepetTheme.hairline) + ) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } + + private var modeMenu: some View { + Menu { + ForEach(ChatMode.allCases) { m in + Button(m.label(lang)) { mode = m } + } + } label: { + HStack(spacing: 6) { + Text(mode.label(lang)).font(CodepetTheme.inter(13)) + Image(systemName: "chevron.down") + .font(.system(size: 9)) + .foregroundColor(CodepetTheme.mutedText) + } + .foregroundColor(CodepetTheme.bodyText) + .padding(.horizontal, 12) + .frame(height: 30) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(CodepetTheme.hairline) + ) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } + + private var sendButton: some View { + Button(action: onSend) { + Image(systemName: "arrow.up") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 34, height: 34) + .background( + Circle().fill(canSend ? CodepetTheme.accentPurple : CodepetTheme.mutedText) + ) + } + .buttonStyle(.plain) + .disabled(!canSend) + } +} + +#if DEBUG +private struct ChatComposerPreviewHost: View { + @State private var draft = "" + @State private var mode: ChatMode = .ask + @FocusState private var focused: Bool + var body: some View { + ChatComposer( + draft: $draft, mode: $mode, canSend: !draft.isEmpty, + focus: $focused, + placeholder: "Ask anything about your company…", + quickActions: ["Run a task", "Review the roadmap"], + onSend: {}, onQuickAction: { _ in } + ) + .frame(width: 640) + .padding() + } +} + +#Preview("ChatComposer") { ChatComposerPreviewHost() } +#endif +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -30` +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 3: Run the full test suite (no regressions)** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -40` +Expected: all tests pass (composer is not yet wired into anything, so nothing changes behaviorally). + +- [ ] **Step 4: Commit** + +```bash +git add codepet/Views/Copilot/ChatComposer.swift +git commit -m "feat(chat): add reusable ChatComposer view + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 3: `ChatEmptyState` hero view + +**Files:** +- Create: `codepet/Views/Copilot/ChatEmptyState.swift` + +**Interfaces:** +- Consumes: `CodepetTheme`; `\.uiLanguage`; `\.accessibilityReduceTransparency`. +- Produces: + ```swift + struct ChatEmptyState: View { + let companyName: String + let quickActions: [String] + let onQuickAction: (String) -> Void + @ViewBuilder var composer: Composer + } + ``` + Layout order top→bottom: hero greeting, `composer`, quick-action pills. + +- [ ] **Step 1: Write the view + preview** + +Create `codepet/Views/Copilot/ChatEmptyState.swift`: + +```swift +import SwiftUI + +/// The chat empty state: a centered, personalized hero greeting, the composer +/// (injected so the parent keeps ownership of draft/mode), and a row of +/// capability quick-action pills. Replaces the old left-aligned welcome text. +struct ChatEmptyState: View { + let companyName: String + let quickActions: [String] + let onQuickAction: (String) -> Void + @ViewBuilder var composer: Composer + + @Environment(\.uiLanguage) private var lang + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + var body: some View { + VStack(spacing: 28) { + greeting + .font(CodepetTheme.inter(34, weight: .semibold)) + .multilineTextAlignment(.center) + .foregroundColor(CodepetTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 24) + .background(glow) + + composer + .frame(maxWidth: 680) + + pills + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 40) + } + + /// "How can I help you build {company} today?" with the company in accent. + private var greeting: Text { + let accent = Text(companyName).foregroundColor(CodepetTheme.accentPurple) + switch lang { + case .vi: return Text("Mình giúp gì cho ") + accent + Text(" hôm nay?") + case .en: return Text("How can I help you build ") + accent + Text(" today?") + } + } + + /// One faint accent radial behind the greeting. Suppressed under + /// reduce-transparency; kept subtle so it reads in light AND dark. + @ViewBuilder private var glow: some View { + if reduceTransparency { + Color.clear + } else { + RadialGradient( + colors: [CodepetTheme.accentPurple.opacity(0.16), .clear], + center: .center, startRadius: 0, endRadius: 260 + ) + .blur(radius: 24) + .allowsHitTesting(false) + } + } + + /// Quick-action pills. One row when it fits, two rows otherwise, so a narrow + /// column never clips them. + private var pills: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { ForEach(quickActions, id: \.self) { pill($0) } } + VStack(spacing: 10) { + HStack(spacing: 10) { ForEach(firstHalf, id: \.self) { pill($0) } } + HStack(spacing: 10) { ForEach(secondHalf, id: \.self) { pill($0) } } + } + } + } + + private var firstHalf: [String] { Array(quickActions.prefix((quickActions.count + 1) / 2)) } + private var secondHalf: [String] { Array(quickActions.suffix(quickActions.count / 2)) } + + private func pill(_ text: String) -> some View { + Button { onQuickAction(text) } label: { + Text(text) + .font(CodepetTheme.inter(13)) + .foregroundColor(CodepetTheme.bodyText) + .padding(.horizontal, 15) + .padding(.vertical, 8) + .overlay(Capsule().stroke(CodepetTheme.hairline)) + } + .buttonStyle(.plain) + } +} + +#if DEBUG +#Preview("ChatEmptyState") { + ChatEmptyState( + companyName: "Acme", + quickActions: ["Run a task", "Review the roadmap", "Set up a department", "Summarize where we are"], + onQuickAction: { _ in } + ) { + RoundedRectangle(cornerRadius: 16).fill(CodepetTheme.surface).frame(height: 96).frame(maxWidth: 680) + } + .frame(width: 900, height: 620) +} +#endif +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -30` +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 3: Commit** + +```bash +git add codepet/Views/Copilot/ChatEmptyState.swift +git commit -m "feat(chat): add ChatEmptyState centered hero view + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 4: Wire composer + empty state into `CopilotChatView`; delete old input, greeting, and Let's-build stub + +**Files:** +- Modify: `codepet/Views/Copilot/CopilotChatView.swift` + +**Interfaces:** +- Consumes: `ChatMode` (Task 1), `ChatComposer` (Task 2), `ChatEmptyState` (Task 3). +- Produces: no new public API. Internal `send()` now applies `mode.shape(...)`; new private `runQuickAction(_:)`, `quickActions`, `placeholder`, `composerView`, and `@State mode`. + +This task is a mechanical restructure of working code. Do NOT rewrite the message +list, bubbles, header, or thread logic — only move/replace the pieces named below. + +- [ ] **Step 1: Add mode state** + +In `CopilotChatView`, next to `@State private var draft = ""` (around line 8), add: + +```swift + @State private var mode: ChatMode = .ask +``` + +- [ ] **Step 2: Replace the `body` layout** + +Replace the current `var body` (the `VStack(spacing: 0) { header; Divider(); if showHistory {…} else { messageList; letsBuild }; Divider(); inputBar }`) with: + +```swift + var body: some View { + VStack(spacing: 0) { + header + Divider() + if showHistory { + ThreadListView(showHistory: $showHistory) + } else if companyStore.chatMessages.isEmpty { + ChatEmptyState(companyName: companyName, + quickActions: quickActions, + onQuickAction: runQuickAction) { + composerView.frame(maxWidth: 680) + } + } else { + messageList + Divider() + composerView.padding(10) + } + } + .frame(maxHeight: .infinity) + } +``` + +- [ ] **Step 3: Add the shared composer + helpers; delete the old `inputBar`, `letsBuild`, `greeting`, `quickStarts`** + +Delete these existing members entirely: `letsBuild`, `greeting`, `quickStarts`, `inputBar`. +In `messageList`, delete the empty-state branch line `if companyStore.chatMessages.isEmpty { greeting }` (the empty state is now handled in `body`; `messageList` only renders when there are messages). + +Add these new members: + +```swift + /// The one composer instance, reused in the empty hero and docked in an + /// active conversation. State (draft/mode/focus) stays here so both + /// placements share the same value. + private var composerView: some View { + ChatComposer( + draft: $draft, + mode: $mode, + canSend: canSend, + focus: $inputFocused, + placeholder: placeholder, + quickActions: quickActions, + onSend: send, + onQuickAction: runQuickAction + ) + } + + private var placeholder: String { + lang == .vi + ? "Hỏi \(companionName) bất cứ điều gì về công ty…" + : "Ask \(companionName) anything about your company…" + } + + /// Capability quick-actions — the strings are complete intents, so they are + /// sent as-is (NOT mode-shaped). Replaces the old `quickStarts`. + private var quickActions: [String] { + lang == .vi + ? ["Chạy một tác vụ", "Xem lộ trình", "Thiết lập một phòng ban", "Tóm tắt tình hình công ty"] + : ["Run a task", "Review the roadmap", "Set up a department", "Summarize where we are"] + } + + /// Send a canned capability prompt through the normal chat path. Bypasses + /// mode-shaping (the string already expresses the intent). Guarded like send. + private func runQuickAction(_ text: String) { + guard !companyStore.isCompanionTyping, !companyStore.isStreaming else { return } + showHistory = false + Task { await companyStore.sendChat(text, language: lang) } + } +``` + +- [ ] **Step 4: Update `send()` to apply the mode** + +Replace the existing `send()` with: + +```swift + private func send() { + guard canSend else { return } + let text = mode.shape(draft, language: lang) + draft = "" + showHistory = false // sending always returns to the live conversation + Task { await companyStore.sendChat(text, language: lang) } + } +``` + +- [ ] **Step 5: Build to verify it compiles** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -30` +Expected: `** BUILD SUCCEEDED **`. If it fails on a now-unused symbol, confirm `letsBuild`/`greeting`/`quickStarts`/`inputBar` were fully removed and had no other references. + +- [ ] **Step 6: Run the full test suite (no regressions)** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -40` +Expected: all tests pass (send/thread/draft behavior unchanged). + +- [ ] **Step 7: Manual verification (run the app)** + +Launch the app (foreground). Verify: +- Empty chat shows the centered hero greeting with the company name in accent, the composer, and 4 quick-action pills. +- Typing + Return sends; the send button enables only with text and while not streaming. +- Switching the mode menu to Plan/Build then sending produces a shaped message (byte replies with a plan / attempts the work). +- Tapping a pill sends its capability prompt. +- Once messages exist, the composer is docked at the bottom and the `+` menu lists the quick actions. +- History toggle still opens the thread switcher. + +- [ ] **Step 8: Commit** + +```bash +git add codepet/Views/Copilot/CopilotChatView.swift +git commit -m "feat(chat): wire composer + empty state, drop old input/greeting/lets-build + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 5: Bubble restyle + light/dark & reduce-motion verification + +**Files:** +- Modify: `codepet/Views/Copilot/CopilotChatView.swift` + +**Interfaces:** +- Consumes: nothing new. Pure visual polish on `CopilotBubble.textBubble` and `messageList` spacing. + +- [ ] **Step 1: Increase message spacing** + +In `messageList`, change the messages `VStack(alignment: .leading, spacing: 10)` to `spacing: 14`. + +- [ ] **Step 2: Soften the bubble radius** + +In `CopilotBubble.textBubble`, change the background `RoundedRectangle(cornerRadius: 12, style: .continuous)` to `cornerRadius: 14`. + +- [ ] **Step 3: Build** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -30` +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 4: Verify both appearances + reduce-motion** + +Run the app and toggle macOS Appearance (System Settings → Appearance, or the in-app AppTheme setting) between Light and Dark: +- **Dark:** hero greeting legible, glow subtle (not a bright blob), composer/pills read against the charcoal surface. +- **Light (cream):** greeting legible on cream, glow barely-there (not a grey smear), composer border visible. +- Enable System Settings → Accessibility → Display → Reduce transparency and confirm the greeting glow disappears (no layout shift). + +- [ ] **Step 5: Run the full test suite** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -40` +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add codepet/Views/Copilot/CopilotChatView.swift +git commit -m "style(chat): restyle bubbles + verify light/dark hero + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review + +**Spec coverage:** +- Component structure → Tasks 1–4 (ChatMode, ChatComposer, ChatEmptyState, CopilotChatView shell). ✓ +- Empty-state hero (centered greeting, glow, pills, GSF font) → Task 3. ✓ +- Composer (`+` quick-actions menu, mode control, send, canSend gate, multiline) → Task 2 + Task 4 wiring. ✓ +- Modes honest / client-side shaping → Task 1 + Task 4 `send()`. ✓ +- Active conversation (docked composer, bubble restyle, delete Let's-build stub) → Task 4 + Task 5. ✓ +- Header unchanged → untouched (confirmed no task modifies it beyond leaving it as-is). ✓ +- Tokens + dynamic light/dark + reduce-motion → tokens used throughout; verified in Task 5. ✓ +- No fake affordances → `+` is a real menu; no attach; modes real. ✓ +- Testing (ChatMode unit tests; behavior preserved) → Task 1 tests + full-suite runs in Tasks 2/4/5. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; commands have expected output. ✓ + +**Type consistency:** `ChatMode.shape(_:language:)` and `label(_:)` signatures match between Task 1 (definition), Task 2 (`ChatComposer` mode menu), and Task 4 (`send()`). `ChatComposer`'s parameter list in Task 2 matches its construction in Task 4's `composerView`. `ChatEmptyState`'s init (`companyName`, `quickActions`, `onQuickAction`, `composer`) matches Task 4's call site. ✓ diff --git a/docs/superpowers/plans/2026-07-27-sidebar-restructure.md b/docs/superpowers/plans/2026-07-27-sidebar-restructure.md new file mode 100644 index 0000000..dd48fbd --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-sidebar-restructure.md @@ -0,0 +1,49 @@ +# Sidebar Restructure — Implementation Plan + +**Goal:** Replace the top bar with a left **SidebarView** (brand → New chat → Recent/History → Workspace nav → Upgrade/account), optimize History into the sidebar's "Recent" section, and remove the "Your team · guiding · {company}" chat header. Chat stays the full-width home to the right of the sidebar. Branch `feat/chat-redesign`. Reference mock: `scratchpad/chat-v4-sidebar.png` (purple brand). + +## Global constraints +Native SwiftUI; CodepetTheme tokens; bilingual vi/en; reduce-transparency-safe glows. `xcodebuild` FOREGROUND only. Known `CompanyStoreScaffordOnboardingTests` Firebase flake may crash-retry; nothing else new may fail. Commit trailer required. + +## Reuse (exact) +- `AccountMenuView()` (no-arg, `codepet/Views/Shell/AccountMenuView.swift`). +- `TopbarCounts.tasks(company.tasks)`, `.library(company.library)`, `.envPending(enabled: company.enabledTools)`. +- Threads: `companyStore.threads: [ChatThread]` (`ChatThread{ id, title:String?, updatedAt:Date }`), `sortThreadsByRecent(_)`, `relativeTime(_:now:)`, and `companyStore.newChat()/switchThread(_)/renameThread(_:title:)/deleteThread(_)/activeThreadId`. +- Nav: `AppView.navTabs` = [summary, roadmap, secondBrain, company, tasks, library, environment]; `companyStore.select(_)`, `companyStore.view`, `.chat` for home. + +--- + +### Task SB-1: `SidebarView` (new, not wired) +**Create** `codepet/Views/Shell/SidebarView.swift`. A ~250pt-wide column (`CodepetTheme` surface, right hairline border): +- **Brand row:** "Codepet" wordmark (`CodepetTheme.pixel(16)`) as a `Button { companyStore.selectedDeptKey = nil; companyStore.select(.chat) }` (home), + a collapse chevron button taking a `@Binding var collapsed` (SB-2 supplies it; for SB-1 use a local `@State` default so it previews). +- **New chat:** purple-gradient pill `Button { companyStore.newChat(); companyStore.select(.chat) }` (guard: no-op while `isCompanionTyping`/`isStreaming` — mirror ThreadListView's isChatBusy). +- **RECENT (optimized History):** section label; rows from `sortThreadsByRecent(companyStore.threads)` grouped by date bucket using `relativeTime` (Today / Yesterday / Earlier — bucket by comparing `Calendar` day of `updatedAt` vs `Date()`). Each row: title (or "New chat") + faint relative time; active row (`thread.id == activeThreadId`) highlighted (accentPurple tint); tap → `companyStore.switchThread(thread.id); companyStore.select(.chat)`; a right-click/hover `Menu` (ellipsis) with Rename (inline TextField) + Delete (`deleteThread`). Reuse ThreadListView's rename/delete logic (you may move it here and delete ThreadListView in SB-3, or keep both for now — SB-1 just builds the sidebar version). Cap the list with a scroll. +- **WORKSPACE nav:** section label; one row per `AppView.navTabs` — icon (`v.icon`) + `v.title(lang)` + optional count badge (Tasks/Library/Environment via `TopbarCounts`); active row (`companyStore.view == v`) highlighted; tap → `companyStore.selectedDeptKey = nil; companyStore.select(v)`. +- **Bottom (pinned):** an "Upgrade to Pro" card → `Button { companyStore.select(.billing) }`; an account row = `AccountMenuView()` (it already renders the avatar/name/menu) + a small ⚡ Wake affordance → `companyStore.select(.environment)`. +- `#if DEBUG #Preview`. +**Verify:** build SUCCEEDED. Commit `feat(nav): add SidebarView (brand, New chat, Recent, Workspace, account)`. + +--- + +### Task SB-2: Wire SidebarView into `AppShellView`; remove the top bar +**Modify** `codepet/Views/Shell/AppShellView.swift`: +- Add `@State private var sidebarCollapsed = false`. +- `body`: replace the `VStack { topBar; Divider; content }` with `HStack(spacing: 0) { if !sidebarCollapsed { SidebarView(collapsed: $sidebarCollapsed); Divider() }; content.frame(maxWidth:.infinity, maxHeight:.infinity) }` (+ a small floating "expand" affordance when collapsed, or let the sidebar's own chevron toggle it — pass the binding in). +- Delete `topBar`, `navTab`, `tabCount`, `wakePill`, and the in-topbar Upgrade button (all moved to the sidebar). Keep `content` and its branches unchanged. +- Keep `.background(CodepetTheme.pageBackground)`. +**Verify:** build SUCCEEDED; grep shows no leftover `topBar`/`navTab`/`wakePill` refs. Commit `feat(nav): replace top bar with SidebarView`. + +--- + +### Task SB-3: Remove chat header + in-chat History from `CopilotChatView` +**Modify** `codepet/Views/Copilot/CopilotChatView.swift`: +- Delete the `header` computed var (the "Your team / guiding · {company}" + History toggle) and stop rendering it in `body`. +- Remove `showHistory` state and the `if showHistory { ThreadListView(...) }` branch — history now lives in the sidebar. `body` becomes: `VStack(spacing:0){ if chatMessages.isEmpty { ChatEmptyState{composer} } else { messageList; Divider(); composerView.padding(10) } }` (drop the header/divider). Keep the empty-state, message list, composer, send/focus behavior. +- `send()`/`runQuickAction`: remove the now-dead `showHistory = false` lines. +- If `ThreadListView` is now unused anywhere, delete it (grep first). +**Verify:** build SUCCEEDED; full suite green except known flake. Commit `feat(chat): drop the Your-team header + in-chat History (moved to sidebar)`. + +## Self-review +- Top bar → sidebar (SB-1 + SB-2) ✓; History optimized into Recent (SB-1) ✓; "Your team" header removed (SB-3) ✓; chat full-width beside sidebar (SB-2 content) ✓; onboarding untouched (no task edits ContentView/OnboardingView) ✓. +- Green boundaries: SB-1 additive; SB-2 swaps shell layout; SB-3 strips redundant chat chrome. +- Types: `SidebarView(collapsed:)` used by SB-2 matches SB-1; thread/nav/counts APIs verified above. diff --git a/docs/superpowers/plans/2026-07-28-card-grammar-readability.md b/docs/superpowers/plans/2026-07-28-card-grammar-readability.md new file mode 100644 index 0000000..1626a72 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-card-grammar-readability.md @@ -0,0 +1,345 @@ +# Message card grammar + chat readability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Give the seven chat message payloads one tinted-card grammar with six semantic hues (gold = decision owed), and move chat reading text from the small pixel font to `CodepetTheme.inter(~15)`. + +**Architecture:** A pure `MessageCardStyle` (kind + hue) + one `MessageCard` wrapper view; refactor `CopilotBubble`'s six bespoke payload builders to route through `MessageCard` with the mapped hue, keeping every inner action/closure unchanged; swap `.pixelSystem` reading text to `CodepetTheme.inter` per a fixed scale. No model/CF/schema change. + +**Tech Stack:** SwiftUI (macOS), CodepetTheme, Xcode `xcodebuild`. + +## Global Constraints + +- Repo/branch: `My-Outcasts/codepet`, `feat/chat-redesign` (PR #39). Nothing merges — branch held. Rebase over any concurrent branch commits before pushing. +- Build/test in the **FOREGROUND** (never background `xcodebuild`): `xcodebuild -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO`. +- Xcode **synchronized folder groups** — new `.swift` files in `codepet/` / `codepetTests/` auto-include; **no `project.pbxproj` edit**. +- SourceKit "Cannot find CodepetTheme/CompanyStore/PetCharacter/CopilotMessage/… in scope" for these files are known FALSE POSITIVES; `xcodebuild build` is authoritative. +- Baseline suite: **0 real failures**; the trailing overall `** TEST FAILED **` is the known `CompanyStoreScaffordOnboardingTests` Firebase-init flake — NOT a regression; the flake also makes the total test COUNT wobble (crash-retry), so judge by "0 real failures," not the count. +- **No behaviour change** to any payload action (Approve/Redo/Revise, nav activate, setup enable, interview send/skip, first-run action, noted). This restyles the container + text only. +- `producing` is NOT a card — it already routes to `ChatThinkingRow`; do not touch it. +- Commit trailer on every commit: `Co-Authored-By: Claude Opus 4.8 (1M context) `. +- Visual verification (hues per payload, approved-draft recede, larger text, light+dark) is human, on a signed build — the controller builds & launches it after the tasks. + +## Hue map (used in Task 2) + +| kind | hue expression | +|---|---| +| draft (unapproved) | `CodepetTheme.accentGold` | +| draft (approved) | `CodepetTheme.accentTeal` | +| interview | `CodepetTheme.accentBlue` | +| setupSuggestion | `CodepetTheme.accentTeal` | +| firstRunAction | companion accent (`PetCharacter.all[companyStore.company.companionId]?.color ?? CodepetTheme.accentPurple`) | +| noted | `CodepetTheme.mutedText` | +| navChip | `CodepetTheme.hairline` | + +## Font scale (used in Task 2 — reading text only; pixel font untouched elsewhere) + +| role | before | after | +|---|---|---| +| bubble body; card title/question | `pixelSystem(12[, .semibold])` | `CodepetTheme.inter(15[, weight: .semibold])` | +| setup name | `pixelSystem(12, .semibold)` | `CodepetTheme.inter(14, weight: .semibold)` | +| secondary (draft body, why lines, noted fact) | `pixelSystem(11)` / `pixelSystem(10)` | `CodepetTheme.inter(13)` | +| interview answer field | `pixelSystem(12)` | `CodepetTheme.inter(14)` | +| primary buttons (Approve/Redo, Send/Skip, verb, nav, first-run) | `pixelSystem(10–11, .semibold)` | `CodepetTheme.inter(12, weight: .semibold)` | +| revise chips (smallest) | `pixelSystem(9, .semibold)` | `CodepetTheme.inter(11, weight: .semibold)` | + +--- + +### Task 1: `MessageCardStyle` (pure) + tests + +**Files:** +- Create: `codepet/Models/MessageCardStyle.swift` +- Test: `codepetTests/MessageCardStyleTests.swift` + +**Interfaces:** +- Produces: `enum MessageCardKind { case draft, interview, setupSuggestion, firstRunAction, noted, navChip }`; `MessageCardStyle.kind(for: CopilotMessage) -> MessageCardKind?`; `MessageCardStyle.hue(for: MessageCardKind, companionAccent: Color) -> Color`. Consumed by Task 2. +- Consumes: `CopilotMessage` fields (`producing`, `draft`, `interview`, `setupSuggestion`, `firstRunAction`, `noted`, `navChip`), `CodepetTheme` accent tokens. + +- [ ] **Step 1: Write the failing tests** + +Create `codepetTests/MessageCardStyleTests.swift`: + +```swift +import XCTest +import SwiftUI +@testable import codepet + +final class MessageCardStyleTests: XCTestCase { + private let sentinel = Color.pink // stand-in companion accent + + func testHueTable() { + XCTAssertEqual(MessageCardStyle.hue(for: .draft, companionAccent: sentinel), CodepetTheme.accentGold) + XCTAssertEqual(MessageCardStyle.hue(for: .interview, companionAccent: sentinel), CodepetTheme.accentBlue) + XCTAssertEqual(MessageCardStyle.hue(for: .setupSuggestion, companionAccent: sentinel), CodepetTheme.accentTeal) + XCTAssertEqual(MessageCardStyle.hue(for: .noted, companionAccent: sentinel), CodepetTheme.mutedText) + XCTAssertEqual(MessageCardStyle.hue(for: .navChip, companionAccent: sentinel), CodepetTheme.hairline) + // firstRunAction returns the companion accent verbatim + XCTAssertEqual(MessageCardStyle.hue(for: .firstRunAction, companionAccent: sentinel), sentinel) + } + + func testKindNilForPlainText() { + let m = CopilotMessage(role: .companion, text: "hello") + XCTAssertNil(MessageCardStyle.kind(for: m)) + } + + func testKindNilForProducing() { + let m = CopilotMessage(role: .companion, text: "", producing: true) + XCTAssertNil(MessageCardStyle.kind(for: m)) + } + + func testKindPerPayload() { + // Construct minimal valid instances of each payload type by reading their + // initializers (Deliverable, InterviewGap, SetupAction, FirstRunAction, + // RememberedFact, NavAction). Set them via CopilotMessage's init args. + // draft: + XCTAssertEqual(MessageCardStyle.kind(for: msg(draft: minimalDraft())), .draft) + // interview: + XCTAssertEqual(MessageCardStyle.kind(for: msg(interview: minimalGap())), .interview) + // setupSuggestion: + XCTAssertEqual(MessageCardStyle.kind(for: msg(setupSuggestion: minimalSetup())), .setupSuggestion) + // firstRunAction: + XCTAssertEqual(MessageCardStyle.kind(for: msg(firstRunAction: minimalAction())), .firstRunAction) + // noted: + XCTAssertEqual(MessageCardStyle.kind(for: msg(noted: [minimalFact()])), .noted) + // navChip: + XCTAssertEqual(MessageCardStyle.kind(for: msg(navChip: minimalNav())), .navChip) + } + + func testPrecedenceDraftBeatsInterview() { + let m = msg(draft: minimalDraft(), interview: minimalGap()) + XCTAssertEqual(MessageCardStyle.kind(for: m), .draft) + } + + // Helpers: implement `msg(...)` as a thin wrapper over CopilotMessage.init with + // the given payload, and the `minimalX()` factories by reading each payload + // type's initializer in codepet/Models/. Keep them tiny — just enough to be non-nil. +} +``` + +Implementer note: fill the `minimalX()` factories by reading each payload type's initializer in `codepet/Models/` (Deliverable, InterviewGap, SetupAction, FirstRunAction, RememberedFact, NavAction). They only need to be valid non-nil instances; field values are irrelevant to `kind(for:)`. + +- [ ] **Step 2: Run tests → RED** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "MessageCardStyle|error:" | tail -8` +Expected: compile failure — `MessageCardStyle` / `MessageCardKind` undefined. + +- [ ] **Step 3: Implement `MessageCardStyle`** + +Create `codepet/Models/MessageCardStyle.swift`: + +```swift +import SwiftUI + +/// The six interactive message-card kinds and their single semantic hue. +/// Pure + testable. `producing` and plain-text messages are NOT cards (nil). +enum MessageCardKind { case draft, interview, setupSuggestion, firstRunAction, noted, navChip } + +enum MessageCardStyle { + /// Which card kind a message is, from its payload. nil for a producing + /// placeholder or a plain-text message. Precedence (for the pathological + /// multi-payload case, and to pin test behaviour): + /// draft > interview > setupSuggestion > firstRunAction > noted > navChip. + static func kind(for m: CopilotMessage) -> MessageCardKind? { + if m.producing { return nil } + if m.draft != nil { return .draft } + if m.interview != nil { return .interview } + if m.setupSuggestion != nil { return .setupSuggestion } + if m.firstRunAction != nil { return .firstRunAction } + if let noted = m.noted, !noted.isEmpty { return .noted } + if m.navChip != nil { return .navChip } + return nil + } + + /// The single hue that carries the card's meaning. Gold = a decision is owed. + static func hue(for kind: MessageCardKind, companionAccent: Color) -> Color { + switch kind { + case .draft: return CodepetTheme.accentGold + case .interview: return CodepetTheme.accentBlue + case .setupSuggestion: return CodepetTheme.accentTeal + case .firstRunAction: return companionAccent + case .noted: return CodepetTheme.mutedText + case .navChip: return CodepetTheme.hairline + } + } +} +``` + +Note: `hue(for: .draft, …)` returns gold unconditionally; the *approved* draft's recede to teal is handled at the call site (Task 2), which knows `draftApproved`. + +- [ ] **Step 4: Run tests → GREEN** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "MessageCardStyleTests|Executed [0-9]+ tests" | tail -6` +Expected: MessageCardStyleTests pass; suite shows 0 real failures. + +- [ ] **Step 5: Commit** + +```bash +git add codepet/Models/MessageCardStyle.swift codepetTests/MessageCardStyleTests.swift +git commit -m "feat(chat): pure MessageCardStyle (kind + six semantic hues) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: `MessageCard` wrapper + refactor `CopilotBubble` + readability + +**Files:** +- Create: `codepet/Views/Copilot/MessageCard.swift` +- Modify: `codepet/Views/Copilot/CopilotChatView.swift` (the six `CopilotBubble` builders + `textBubble` fonts) + +**Interfaces:** +- Consumes: `MessageCardStyle.hue(...)` (Task 1), `CodepetTheme`, `CompanyStore` (already in `CopilotBubble`'s environment), `PetCharacter`. +- Produces: `MessageCard` view. Final task. + +- [ ] **Step 1: Create the `MessageCard` wrapper** + +Create `codepet/Views/Copilot/MessageCard.swift`: + +```swift +import SwiftUI + +/// The one tinted-card construction shared by every interactive chat payload. +/// Only the hue varies (see MessageCardStyle): hue @12% over surface, a same-hue +/// 1pt border, smooth radius 12, uniform padding — matching the redesign's card +/// style. Left-aligned, fills the column width; callers add their own trailing +/// Spacer if they want the card to hug the leading edge. +struct MessageCard: View { + let hue: Color + @ViewBuilder var content: Content + + var body: some View { + content + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(hue.opacity(0.12))) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(hue.opacity(0.9), lineWidth: 1)) + } +} +``` + +- [ ] **Step 2: Add a companion-accent helper to `CopilotBubble`** + +In `CopilotChatView.swift`, inside `struct CopilotBubble`, add near the existing `companionName` computed property: + +```swift + private var companionAccent: Color { + PetCharacter.all[companyStore.company.companionId]?.color ?? CodepetTheme.accentPurple + } +``` + +- [ ] **Step 3: Refactor each payload builder to use `MessageCard` + the font scale** + +Apply this transformation to all six builders in `CopilotBubble`: replace the ad-hoc container (`CodepetCard { … }`, or a bare `Capsule`/chip) with `MessageCard(hue: ) { }`, keep the trailing `Spacer(minLength: 24)` where present, keep **every** button/closure/`Task { … }` exactly, and swap `.pixelSystem(...)` reading fonts to `CodepetTheme.inter(...)` per the Font scale table. + +**Worked example — `setupCard` (teal). Before → After:** + +Before (current): +```swift + return HStack { + CodepetCard { + VStack(alignment: .leading, spacing: 8) { + Text(name) + .font(.pixelSystem(size: 12, weight: .semibold)) + .foregroundColor(CodepetTheme.primaryText) + if let why, !why.isEmpty { + Text(why) + .font(.pixelSystem(size: 11)) + .foregroundColor(CodepetTheme.mutedText) + .fixedSize(horizontal: false, vertical: true) + } + Button { Task { await companyStore.activateSetup(setup) } } label: { + Text(verb) + .font(.pixelSystem(size: 10, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 12).padding(.vertical, 5) + .background(Capsule().fill(CodepetTheme.accentPurple)) + }.buttonStyle(.plain) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + } + Spacer(minLength: 24) + } +``` + +After: +```swift + return HStack { + MessageCard(hue: MessageCardStyle.hue(for: .setupSuggestion, companionAccent: companionAccent)) { + VStack(alignment: .leading, spacing: 8) { + Text(name) + .font(CodepetTheme.inter(14, weight: .semibold)) + .foregroundColor(CodepetTheme.primaryText) + if let why, !why.isEmpty { + Text(why) + .font(CodepetTheme.inter(13)) + .foregroundColor(CodepetTheme.mutedText) + .fixedSize(horizontal: false, vertical: true) + } + Button { Task { await companyStore.activateSetup(setup) } } label: { + Text(verb) + .font(CodepetTheme.inter(12, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 12).padding(.vertical, 5) + .background(Capsule().fill(CodepetTheme.accentPurple)) + }.buttonStyle(.plain) + } + } + Spacer(minLength: 24) + } +``` +(Note: the inner `.padding(12)`/`.frame(maxWidth:.infinity)` move into `MessageCard`, so drop them from the inner `VStack`. The action `Button`/`Task` is unchanged.) + +**Apply the same pattern to the other five:** +- **`draftCard`** — hue `= message.draftApproved ? CodepetTheme.accentTeal : CodepetTheme.accentGold`. Wrap the existing draft `VStack` (title/body tap-to-open, the approved "Added to Library" row, the Approve/Redo `HStack`, and the Revise chips `HStack`) in `MessageCard(hue:)`; drop the old `CodepetCard` + its inner `.padding(12)`/`.frame`. Fonts: title `inter(15,.semibold)`, body `inter(13)`, "Added to Library" `inter(12,.semibold)`, Approve/Redo `inter(12,.semibold)`, revise chips `inter(11,.semibold)`. Keep `.sheet(isPresented:)`, `onTapGesture`, and all `Task { await … }` calls verbatim. +- **`interviewCard`** — hue `.interview` (blue). Wrap the existing `VStack` (ask/why/TextField/Send/Skip) in `MessageCard`; drop the old `RoundedRectangle` background + inner `.padding(12)`. Fonts: ask `inter(15,.semibold)`, why `inter(13)`, TextField `inter(14)`, Send/Skip `inter(12,.semibold)`. Keep the `interviewDraft` binding + both `Task { await companyStore.answerInterview(...) }` closures verbatim. +- **`notedChip`** — hue `.noted` (muted). Wrap the facts `VStack` in `MessageCard`; drop the per-fact `Capsule().fill(surface)`. Fonts: each fact `inter(13)`. +- **`navChip`** — hue `.navChip` (hairline). Wrap the "Go to {label}" `Button` in `MessageCard`; the button keeps `companyStore.activateNav(nav)`. Font `inter(12,.semibold)`. (Because the hairline tint is subtle, keep the button's own filled `Capsule().fill(CodepetTheme.accentPurple)` so the tap target still reads as a control inside the neutral card.) +- **`actionButton` (firstRunAction)** — hue `.firstRunAction` (companion accent). Wrap the "Do it with me: {task}" `Button` in `MessageCard`; keep `runFirstRunAction`. Font `inter(13,.semibold)`. (The greeting `textBubble` above it in `body` lines 187–192 stays a plain bubble — only the action becomes the card.) + +- [ ] **Step 4: Readability on the plain bubbles — `textBubble`** + +In `textBubble`, change both the `me` and companion message `Text(message.text)` fonts from `.pixelSystem(size: 12)` to `CodepetTheme.inter(15)`. Leave layout/colors/alignment unchanged. + +- [ ] **Step 5: Build** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` +Expected: `** BUILD SUCCEEDED **`. Fix any leftover reference (e.g. an orphaned `.padding` now doubled). + +- [ ] **Step 6: Run the full suite** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "codepetTests.xctest' (passed|failed)|Executed [0-9]+ tests" | tail -3` +Expected: `codepetTests.xctest` shows 0 real failures (known Firebase flake aside). + +- [ ] **Step 7: Commit** + +```bash +git add codepet/Views/Copilot/MessageCard.swift codepet/Views/Copilot/CopilotChatView.swift +git commit -m "feat(chat): one tinted card grammar for all payloads + Inter reading text + +MessageCard wraps every payload (draft=gold decision-owed, interview=blue, +setup=teal, first-run=companion, noted=muted, nav=hairline; approved draft +recedes to teal). Chat body + card text move pixelSystem -> inter(~15) for +Claude/ChatGPT-grade readability. Actions unchanged. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Post-implementation (controller, human sign-off) + +Rebase over any concurrent branch commits, push, then build & launch signed (quit stale instance by pid, `xcodebuild … -allowProvisioningUpdates`, `open`). Founder verifies on screen: each payload is a tinted card with its hue; a draft awaiting approval is gold and recedes to teal after Approve; nav/noted are subtle; text is clearly larger/cleaner in light + dark; Approve/Redo/nav/setup/interview all still work. + +--- + +## Self-Review + +**1. Spec coverage:** §10 kind+hue → Task 1. `MessageCard` construction → Task 2 Step 1. Six builders wrapped w/ mapped hue + approved-draft recede → Task 2 Step 3. Readability font scale → Task 2 Steps 3–4. Tests (hue table, kind per payload, nil for plain/producing, precedence) → Task 1. Build/suite/signed visual → Task 2 + post. ✓ + +**2. Placeholder scan:** Concrete code for `MessageCardStyle`, `MessageCard`, the hue/font tables, and a fully-worked `setupCard` example; the other five have exact hue + font + "keep actions" instructions. The only implementer-filled bits are the test `minimalX()` fixtures (payload types whose inits live in the repo) — explicitly flagged, not a vague gap. ✓ + +**3. Type consistency:** `MessageCardStyle.kind/hue` and `MessageCardKind` defined Task 1, used Task 2. `MessageCard(hue:)` defined Task 2 Step 1, used Step 3. `companionAccent` helper defined Step 2, used Step 3. Hue tokens (`accentGold/accentBlue/accentTeal/mutedText/hairline`) confirmed present in `CodepetTheme`. Font API `CodepetTheme.inter(_:weight:)` matches existing usage. ✓ diff --git a/docs/superpowers/plans/2026-07-28-chat-containment-cohesion.md b/docs/superpowers/plans/2026-07-28-chat-containment-cohesion.md new file mode 100644 index 0000000..97a98c8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-chat-containment-cohesion.md @@ -0,0 +1,298 @@ +# Chat containment + cohesion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Contain the active-conversation composer in the same 720pt column as the message list, share one ambient purple backdrop behind both the empty and active chat states, and drop the full-width divider. + +**Architecture:** A structure-preserving layout pass on the native chat surface. Extract the empty state's inline glow into a reusable `ChatBackdrop` and hang it once behind the whole `CopilotChatView`; center the docked composer with the same `Spacer / maxWidth: 720 / Spacer` pattern the message list already uses; remove the divider (the composer's `floatingShadow` carries separation). No composer internals, message-bubble, or `CompanyStore`/chat-logic changes. + +**Tech Stack:** SwiftUI (macOS), CodepetTheme design tokens, Xcode `xcodebuild`. + +## Global Constraints + +- Target repo/branch: `My-Outcasts/codepet`, branch `feat/chat-redesign` (PR #39). Nothing is merged — the branch is held for the user's GUI sign-off. +- Build/test in the **FOREGROUND** (never background `xcodebuild` — it stalls): `xcodebuild -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO`. +- The project uses Xcode **synchronized folder groups** (`PBXFileSystemSynchronizedRootGroup`): a new `.swift` file placed in `codepet/Views/Copilot/` is auto-included in the target — **no `project.pbxproj` edit is required**. +- Column width is **720pt** everywhere (empty + active) — do not change it. +- Known baseline: full suite is **303 tests / 0 real failures**; the overall `** TEST FAILED **` is the `CompanyStoreScaffordOnboardingTests` Firebase-init flake (fixed on PR #40, not this branch) — it is NOT a regression. +- SourceKit shows false-positive "Cannot find CodepetTheme/…" errors for these files out of build context — ignore them; `xcodebuild build` is the authoritative signal. +- Colors/spacing via `CodepetTheme` tokens; respect `accessibilityReduceTransparency`. +- This is a layout/decoration change: there is **no new pure logic to unit-test**. Each task's test cycle is a foreground `xcodebuild build`; Task 2 also runs the full suite. The visual payoff is verified by a human GUI pass (light + dark), out of scope for these coded steps. +- Commit message trailer on every commit: `Co-Authored-By: Claude Opus 4.8 (1M context) `. + +--- + +### Task 1: Shared ambient backdrop behind both chat states + +Extract the empty-state glow into a reusable `ChatBackdrop`, place it once behind the whole `CopilotChatView`, and remove the now-duplicate `brandWash` from `ChatEmptyState`. These ship together: removing `brandWash` alone would strip the empty-state glow, and adding the backdrop without removing `brandWash` would double the glow in the empty state. + +**Files:** +- Create: `codepet/Views/Copilot/ChatBackdrop.swift` +- Modify: `codepet/Views/Copilot/ChatEmptyState.swift` (remove `brandWash` + the `reduceTransparency` env var; unwrap the `ZStack`) +- Modify: `codepet/Views/Copilot/CopilotChatView.swift` (wrap `body` in `ZStack { ChatBackdrop(); … }`) + +**Interfaces:** +- Consumes: `CodepetTheme.accentPurple`, `@Environment(\.accessibilityReduceTransparency)` (existing). +- Produces: `struct ChatBackdrop: View` — a no-argument, inert decoration view (`ChatBackdrop()`), consumed by `CopilotChatView` in Task 1 and relied on by Task 2's active branch sitting inside the same `ZStack`. + +- [ ] **Step 1: Create `ChatBackdrop.swift`** + +Create `codepet/Views/Copilot/ChatBackdrop.swift` with exactly: + +```swift +import SwiftUI + +/// The chat surface's ambient purple radial wash — one shared, inert decoration +/// placed behind BOTH the empty hero and the active conversation so the two read +/// as one continuous surface. Suppressed under Reduce Transparency. Extracted from +/// `ChatEmptyState.brandWash` so the empty and active states share one definition. +struct ChatBackdrop: View { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + var body: some View { + Group { + if !reduceTransparency { + RadialGradient( + gradient: Gradient(colors: [CodepetTheme.accentPurple.opacity(0.16), .clear]), + center: .center, startRadius: 0, endRadius: 420) + .blur(radius: 60) + .allowsHitTesting(false) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} +``` + +- [ ] **Step 2: Remove `brandWash` from `ChatEmptyState` and unwrap the `ZStack`** + +In `codepet/Views/Copilot/ChatEmptyState.swift`, replace the `body` (currently a `ZStack { brandWash; VStack { … } }`) so the `VStack` is the top-level content — the glow now comes from `CopilotChatView`'s `ChatBackdrop`. Change: + +```swift + var body: some View { + ZStack { + brandWash + VStack(spacing: 28) { + CompanionOrb(size: 78) + + greeting + .padding(.horizontal, 24) + + composer + .frame(maxWidth: 720) + + cards + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 40) + } + } +``` + +to: + +```swift + var body: some View { + VStack(spacing: 28) { + CompanionOrb(size: 78) + + greeting + .padding(.horizontal, 24) + + composer + .frame(maxWidth: 720) + + cards + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 40) + } +``` + +Then delete the now-unused `brandWash` computed property entirely: + +```swift + /// Soft purple radial wash behind the whole empty-state column — suppressed + /// under Reduce Transparency. + private var brandWash: some View { + Group { + if !reduceTransparency { + RadialGradient( + gradient: Gradient(colors: [CodepetTheme.accentPurple.opacity(0.16), .clear]), + center: .center, startRadius: 0, endRadius: 420) + .blur(radius: 60) + .allowsHitTesting(false) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +``` + +And delete the now-unused environment property (only `brandWash` read it): + +```swift + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency +``` + +(Leave `@Environment(\.uiLanguage) private var lang` untouched — it is pre-existing and unrelated to this change.) + +- [ ] **Step 3: Wrap `CopilotChatView.body` in a `ZStack` with `ChatBackdrop`** + +In `codepet/Views/Copilot/CopilotChatView.swift`, change the `body` from: + +```swift + var body: some View { + VStack(spacing: 0) { + if companyStore.chatMessages.isEmpty { + ChatEmptyState(line1: greetingLine1, + line2: greetingLine2, + quickActions: quickActions, + onQuickAction: runQuickAction) { + composerView + } + } else { + messageList + Divider() + composerView.padding(10) + } + } + .frame(maxHeight: .infinity) + } +``` + +to (this step only adds the `ZStack { ChatBackdrop(); … }` wrapper; the active branch is still the old `Divider()` + `composerView.padding(10)` and gets rewritten in Task 2): + +```swift + var body: some View { + ZStack { + ChatBackdrop() + VStack(spacing: 0) { + if companyStore.chatMessages.isEmpty { + ChatEmptyState(line1: greetingLine1, + line2: greetingLine2, + quickActions: quickActions, + onQuickAction: runQuickAction) { + composerView + } + } else { + messageList + Divider() + composerView.padding(10) + } + } + .frame(maxHeight: .infinity) + } + } +``` + +- [ ] **Step 4: Build to verify it compiles (this is the test cycle)** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` +Expected: `** BUILD SUCCEEDED **`. If the compiler flags `reduceTransparency` or `lang` as still-referenced, undo only the offending deletion; if it flags them unused, the deletion in Step 2 was correct. + +- [ ] **Step 5: Commit** + +```bash +git add codepet/Views/Copilot/ChatBackdrop.swift codepet/Views/Copilot/ChatEmptyState.swift codepet/Views/Copilot/CopilotChatView.swift +git commit -m "feat(chat): share one ambient backdrop behind empty + active + +Extract ChatEmptyState.brandWash into a reusable ChatBackdrop and hang it +once behind CopilotChatView so the empty hero and the active conversation +read as one continuous purple-washed surface. ChatEmptyState drops its +private brandWash (and the reduceTransparency env only it used); no visual +change to the empty state. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: Contain the docked composer + drop the divider + +Bring the active-conversation composer into the same centered 720pt column as the message list and remove the full-width divider. This is the actual "full-width" fix. + +**Files:** +- Modify: `codepet/Views/Copilot/CopilotChatView.swift` (the active branch of `body`, inside the `ZStack` added in Task 1) + +**Interfaces:** +- Consumes: `ChatBackdrop` + the `ZStack { … VStack { … } }` structure from Task 1; the existing `messageList` (which centers a `maxWidth: 720` VStack via `HStack { Spacer; …; Spacer }`) and `composerView` (the shared `ChatComposer`, already carrying `floatingShadow`). +- Produces: nothing consumed downstream (final task). + +- [ ] **Step 1: Rewrite the active branch — center the composer, drop the divider** + +In `codepet/Views/Copilot/CopilotChatView.swift`, change the active (`else`) branch from: + +```swift + } else { + messageList + Divider() + composerView.padding(10) + } +``` + +to: + +```swift + } else { + messageList + HStack { + Spacer(minLength: 0) + composerView.frame(maxWidth: 720) + Spacer(minLength: 0) + } + .padding(.vertical, 10) + } +``` + +This uses the identical `Spacer / maxWidth: 720 / Spacer` centering `messageList` uses, so the composer's 720 column tracks the message-bubble column at every window width. The `Divider()` is removed — the composer's `floatingShadow` provides separation over the ambient backdrop. + +- [ ] **Step 2: Build to verify it compiles** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 3: Run the full suite to confirm no regression** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "codepetTests.xctest' (passed|failed)|Executed [0-9]+ tests" | tail -3` +Expected: `Executed 303 tests, with 0 failures` on the `codepetTests.xctest` suite. (A trailing overall `** TEST FAILED **` from the known Firebase-init flake is acceptable — confirm the `codepetTests.xctest` line shows 0 failures.) + +- [ ] **Step 4: Commit** + +```bash +git add codepet/Views/Copilot/CopilotChatView.swift +git commit -m "fix(chat): contain the docked composer in the 720 column, drop divider + +The active-conversation composer rendered full-width (composerView.padding +(10)); wrap it in the same Spacer/maxWidth:720/Spacer column the message +list uses so its edges track the bubbles at every width. Drop the +full-width Divider() — the composer's floatingShadow carries the +separation over the shared ChatBackdrop. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Post-implementation (human, out of scope for coded steps) + +Push the branch, then a GUI pass in a signed build (team `YL72VTKBR7`, ⌘R), **light + dark**: +- Active conversation: composer sits in the centered 720 column, edges aligned with bubbles; no edge-to-edge sprawl; no divider. +- Ambient purple glow present and consistent across empty and active (fixed, not scrolling); acceptable over the light cream background. +- Reduce Transparency ON → glow suppressed in both states. +- Empty state looks identical to before. + +--- + +## Self-Review + +**1. Spec coverage:** +- Spec change 1 (contain docked composer) → Task 2, Step 1. ✓ +- Spec change 2 (`ChatBackdrop` extraction + shared behind both states; `ChatEmptyState` drops `brandWash`) → Task 1, Steps 1–3. ✓ +- Spec change 3 (drop the `Divider()`) → Task 2, Step 1. ✓ +- Non-goals (no composer-internal/bubble/logic/width changes) → respected; no task touches those. ✓ +- Testing (build gate + suite 303/0; GUI pass) → Task 1 Step 4, Task 2 Steps 2–3, Post-implementation. ✓ +- Risk "backdrop must be outside the ScrollView" → satisfied: `ChatBackdrop` is a sibling of the `VStack` in the `ZStack`, not inside `messageList`. ✓ + +**2. Placeholder scan:** No TBD/TODO/"handle edge cases"/vague steps — every code step shows the full before/after. ✓ + +**3. Type consistency:** `ChatBackdrop` defined in Task 1 Step 1 is used as `ChatBackdrop()` in Task 1 Step 3 and relied on structurally in Task 2. `composerView`, `messageList`, `greetingLine1/2`, `quickActions`, `runQuickAction` are all existing members, used verbatim. Column cap `720` consistent across both tasks and the spec. ✓ diff --git a/docs/superpowers/plans/2026-07-28-chat-scenario-mockups-agents-working.md b/docs/superpowers/plans/2026-07-28-chat-scenario-mockups-agents-working.md new file mode 100644 index 0000000..96cfd63 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-chat-scenario-mockups-agents-working.md @@ -0,0 +1,602 @@ +# Chat Scenario Mockups + Parallel Agents-Working UI — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add five SwiftUI `#Preview` mock screens of the chat (with the user, the roadmap, tasks, and environment setup) plus a new production-shaped `AgentsWorkingRow` component that shows multiple department agents working in parallel. + +**Architecture:** The four scenario mocks feed fixture `[CopilotMessage]` arrays into the *real* `CopilotBubble` component through one shared `ChatMockData` harness, so they can't drift from production. The one new component, `AgentsWorkingRow`, is a multi-agent sibling of the existing single-agent `ExecLogRow`, backed by a small `AgentRun` value type with pure, unit-tested math. View-layer only — no engine or persistence changes. + +**Tech Stack:** Swift, SwiftUI, XCTest. macOS app target `codepet` (lowercase scheme), tests via `@testable import codepet`. + +## Global Constraints + +- Target/scheme: `codepet` (lowercase). Xcode project: `CodePet.xcodeproj`. +- All mock/preview code is dev-only: wrap in `#if DEBUG … #endif`. +- Reuse existing types verbatim — do NOT redefine: `CopilotMessage`, `ExecStep`, `Deliverable`, `DeliverableKind`, `NavAction(destination:target:)`, `SetupAction(category:name:)`, `RememberedFact(topic:statement:)`, `FirstRunAction(taskId:taskTitle:)`, `PetCharacter.all`, `CompanyStore`, `CodepetTheme`, `CompanionAvatar(companionId:size:isWorking:)`, `MessageCard(hue:) { content }`, `ChatBackdrop`. +- Companion ids: `byte` (display name "Codepet"), `nova`, `crash`, `luna`, `sage`, `glitch`, `null`. Departments by display name: Engineering, Design, Marketing, Sales, Support, Finance, Operations, Legal. +- `AppLanguage` cases are `.vi` and `.en`. +- Chat layout constants (match the shipped spacing pass): column `maxWidth: 760`, gutter `padding(.horizontal, 24)`, list `padding(.top, 40).padding(.bottom, 24)`, inter-message `spacing: 24`. +- Follow TDD for the one logic-bearing unit (`AgentRun`). The pure scenario/preview files carry no logic and are verified by a successful Debug build, not unit tests. + +--- + +### Task 1: `AgentRun` model + pure math + tests + +**Files:** +- Create: `codepet/Views/Copilot/AgentsWorkingRow.swift` (model + math only in this task; the view is added in Task 2) +- Test: `codepetTests/AgentsWorkingRowTests.swift` + +**Interfaces:** +- Consumes: `ExecStep` (existing: `ExecStep(label:done:)`, property `done: Bool`, `id: String`), `AppLanguage`. +- Produces: + - `enum AgentRunStatus: String, Equatable, Codable, CaseIterable { case working, reviewing, done, failed }` with `func label(_ lang: AppLanguage) -> String`. + - `struct AgentRun: Identifiable, Equatable` with init `AgentRun(id:companionId:deptName:taskTitle:steps:status:startedAt:)` and members: `var stepCounter: String`, `var currentStepIndex: Int?`, `func elapsedString(now: Date) -> String`. + +- [ ] **Step 1: Write the failing tests** + +Create `codepetTests/AgentsWorkingRowTests.swift`: + +```swift +import XCTest +@testable import codepet + +final class AgentsWorkingRowTests: XCTestCase { + private func makeRun(steps: [ExecStep], + status: AgentRunStatus = .working, + startedAt: Date = Date(timeIntervalSince1970: 0)) -> AgentRun { + AgentRun(companionId: "byte", deptName: "Engineering", + taskTitle: "Build the API", steps: steps, + status: status, startedAt: startedAt) + } + + func testStepCounterCountsDoneOverTotal() { + let r = makeRun(steps: [ExecStep(label: "a", done: true), + ExecStep(label: "b", done: true), + ExecStep(label: "c", done: false)]) + XCTAssertEqual(r.stepCounter, "2/3") + } + + func testStepCounterNoneDone() { + let r = makeRun(steps: [ExecStep(label: "a", done: false), + ExecStep(label: "b", done: false)]) + XCTAssertEqual(r.stepCounter, "0/2") + } + + func testCurrentStepIndexIsFirstNotDone() { + let r = makeRun(steps: [ExecStep(label: "a", done: true), + ExecStep(label: "b", done: false), + ExecStep(label: "c", done: false)]) + XCTAssertEqual(r.currentStepIndex, 1) + } + + func testCurrentStepIndexNilWhenAllDone() { + let r = makeRun(steps: [ExecStep(label: "a", done: true)]) + XCTAssertNil(r.currentStepIndex) + } + + func testElapsedStringFormatsMinutesSeconds() { + let r = makeRun(steps: []) + XCTAssertEqual(r.elapsedString(now: Date(timeIntervalSince1970: 134)), "2:14") + } + + func testElapsedStringPadsSeconds() { + let r = makeRun(steps: []) + XCTAssertEqual(r.elapsedString(now: Date(timeIntervalSince1970: 8)), "0:08") + } + + func testElapsedStringNeverNegative() { + let r = makeRun(steps: [], startedAt: Date(timeIntervalSince1970: 100)) + XCTAssertEqual(r.elapsedString(now: Date(timeIntervalSince1970: 40)), "0:00") + } + + func testStatusLabelExhaustiveEnglish() { + XCTAssertEqual(AgentRunStatus.working.label(.en), "Working") + XCTAssertEqual(AgentRunStatus.reviewing.label(.en), "Reviewing") + XCTAssertEqual(AgentRunStatus.done.label(.en), "Done") + XCTAssertEqual(AgentRunStatus.failed.label(.en), "Failed") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +xcodebuild test -project CodePet.xcodeproj -scheme codepet \ + -only-testing:codepetTests/AgentsWorkingRowTests CODE_SIGNING_ALLOWED=NO 2>&1 | tail -20 +``` +Expected: FAIL — compile error "cannot find 'AgentRun' / 'AgentRunStatus' in scope". + +- [ ] **Step 3: Write the model + math** + +Create `codepet/Views/Copilot/AgentsWorkingRow.swift`: + +```swift +import SwiftUI + +/// Status of one concurrent department-agent run. +enum AgentRunStatus: String, Equatable, Codable, CaseIterable { + case working, reviewing, done, failed + + func label(_ lang: AppLanguage) -> String { + switch (self, lang) { + case (.working, .vi): return "Đang làm" + case (.working, _): return "Working" + case (.reviewing, .vi): return "Đang duyệt" + case (.reviewing, _): return "Reviewing" + case (.done, .vi): return "Xong" + case (.done, _): return "Done" + case (.failed, .vi): return "Lỗi" + case (.failed, _): return "Failed" + } + } +} + +/// One department agent working on a task, for the inline multi-agent exec-log. +/// A multi-agent analogue of what `ExecLogRow` shows for a single run. +struct AgentRun: Identifiable, Equatable { + let id: String + let companionId: String // resolves avatar + accent via PetCharacter.all + let deptName: String // "Engineering", "Design", … + let taskTitle: String + var steps: [ExecStep] // reuses the existing ExecStep type + var status: AgentRunStatus + let startedAt: Date // for elapsed display + + init(id: String = UUID().uuidString, companionId: String, deptName: String, + taskTitle: String, steps: [ExecStep], status: AgentRunStatus, startedAt: Date) { + self.id = id + self.companionId = companionId + self.deptName = deptName + self.taskTitle = taskTitle + self.steps = steps + self.status = status + self.startedAt = startedAt + } + + /// "4/7" — done steps over total. + var stepCounter: String { "\(steps.filter { $0.done }.count)/\(steps.count)" } + + /// The first not-done step (the spinning one); nil when all are done. + var currentStepIndex: Int? { steps.firstIndex { !$0.done } } + + /// "m:ss" elapsed since `startedAt`. `now` is injected so it is testable and + /// preview-stable (no `Date()` read inside the view). + func elapsedString(now: Date) -> String { + let secs = max(0, Int(now.timeIntervalSince(startedAt))) + return String(format: "%d:%02d", secs / 60, secs % 60) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +xcodebuild test -project CodePet.xcodeproj -scheme codepet \ + -only-testing:codepetTests/AgentsWorkingRowTests CODE_SIGNING_ALLOWED=NO 2>&1 | tail -20 +``` +Expected: PASS — `Executed 8 tests, with 0 failures`, `** TEST SUCCEEDED **`. + +- [ ] **Step 5: Commit** + +```bash +git add codepet/Views/Copilot/AgentsWorkingRow.swift codepetTests/AgentsWorkingRowTests.swift +git commit -F - <<'EOF' +feat(chat): AgentRun model + math for parallel agents-working UI + +Value type for one concurrent department-agent run, with pure +step-counter / current-step / elapsed / status-label helpers, unit-tested. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +--- + +### Task 2: `AgentsWorkingRow` view + `AgentsWorkingMock` preview + +**Files:** +- Modify: `codepet/Views/Copilot/AgentsWorkingRow.swift` (append the view + a `#Preview`) +- Create: `codepet/Views/Copilot/Mocks/AgentsWorkingMock.swift` + +**Interfaces:** +- Consumes: `AgentRun`, `AgentRunStatus` (Task 1); `MessageCard(hue:)`, `CompanionAvatar(companionId:size:isWorking:)`, `PetCharacter.all`, `CodepetTheme`, `ExecStep`. +- Produces: `struct AgentsWorkingRow: View` with init `AgentsWorkingRow(runs: [AgentRun], now: Date = Date())`. + +- [ ] **Step 1: Append the view to `AgentsWorkingRow.swift`** + +Add below the `AgentRun` struct in `codepet/Views/Copilot/AgentsWorkingRow.swift`: + +```swift +/// Inline, in-chat view of MULTIPLE department agents working at once — a +/// multi-agent sibling of `ExecLogRow`, modeled on Codex's run list. One card +/// holds a stacked row per active agent; left-aligned like a companion message. +struct AgentsWorkingRow: View { + let runs: [AgentRun] + /// Injected clock for elapsed display — stable in previews/tests. + var now: Date = Date() + + @Environment(\.uiLanguage) private var lang + + var body: some View { + HStack { + MessageCard(hue: CodepetTheme.accentPurple) { + VStack(alignment: .leading, spacing: 12) { + Text((lang == .vi ? "Đang làm việc" : "Agents at work") + + " · \(runs.count)") + .font(CodepetTheme.inter(10, weight: .semibold)) + .tracking(0.5) + .foregroundColor(CodepetTheme.mutedText) + ForEach(Array(runs.enumerated()), id: \.element.id) { idx, run in + if idx > 0 { Divider().overlay(CodepetTheme.hairline) } + agentRow(run) + } + } + } + Spacer(minLength: 24) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder private func agentRow(_ run: AgentRun) -> some View { + let persona = PetCharacter.all[run.companionId] + let accent = persona?.color ?? CodepetTheme.accentPurple + let name = persona?.name ?? "Codepet" + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 8) { + CompanionAvatar(companionId: run.companionId, size: 22, + isWorking: run.status == .working) + Text("\(name) · \(run.deptName)") + .font(CodepetTheme.inter(13, weight: .semibold)) + .foregroundColor(accent) + Spacer(minLength: 8) + statusPill(run.status) + Text(run.elapsedString(now: now)) + .font(CodepetTheme.inter(11)) + .foregroundColor(CodepetTheme.mutedText) + Text(run.stepCounter) + .font(CodepetTheme.inter(11, weight: .medium)) + .foregroundColor(CodepetTheme.mutedText) + } + Text(run.taskTitle) + .font(CodepetTheme.inter(14)) + .foregroundColor(CodepetTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + ForEach(Array(run.steps.enumerated()), id: \.element.id) { idx, step in + HStack(spacing: 8) { + stepIcon(done: step.done, isCurrent: idx == run.currentStepIndex) + .frame(width: 15, height: 15) + Text(step.label) + .font(CodepetTheme.inter(12)) + .foregroundColor(step.done ? CodepetTheme.bodyText + : (idx == run.currentStepIndex ? CodepetTheme.primaryText + : CodepetTheme.mutedText)) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private func statusPill(_ status: AgentRunStatus) -> some View { + let fg: Color + let bg: Color + switch status { + case .working: fg = CodepetTheme.accentPurple; bg = CodepetTheme.accentPurple.opacity(0.14) + case .reviewing: fg = CodepetTheme.accentGold; bg = CodepetTheme.accentGold.opacity(0.16) + case .done: fg = CodepetTheme.accentTeal; bg = CodepetTheme.accentTeal.opacity(0.16) + case .failed: fg = Color.red; bg = Color.red.opacity(0.14) + } + return Text(status.label(lang)) + .font(CodepetTheme.inter(10, weight: .semibold)) + .foregroundColor(fg) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Capsule().fill(bg)) + } + + // Mirrors ExecLogRow's step icon: done → teal check, current → spinner, else dim ring. + @ViewBuilder private func stepIcon(done: Bool, isCurrent: Bool) -> some View { + if done { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 13)).foregroundColor(CodepetTheme.accentTeal) + } else if isCurrent { + ProgressView().controlSize(.small).scaleEffect(0.7) + } else { + Image(systemName: "circle") + .font(.system(size: 12)).foregroundColor(CodepetTheme.mutedText.opacity(0.45)) + } + } +} +``` + +- [ ] **Step 2: Create the mock preview** + +Create `codepet/Views/Copilot/Mocks/AgentsWorkingMock.swift`: + +```swift +import SwiftUI + +#if DEBUG +#Preview("Agents · working in parallel") { + let base = Date(timeIntervalSince1970: 0) + let runs = [ + AgentRun(companionId: "byte", deptName: "Engineering", + taskTitle: "Building the waitlist API", + steps: [ + ExecStep(label: "Scaffold the route", done: true), + ExecStep(label: "Define the schema", done: true), + ExecStep(label: "Write the handler", done: false), + ExecStep(label: "Add tests", done: false), + ], + status: .working, startedAt: base), + AgentRun(companionId: "luna", deptName: "Design", + taskTitle: "Landing hero visual pass", + steps: [ + ExecStep(label: "Moodboard", done: true), + ExecStep(label: "Layout", done: false), + ExecStep(label: "Typography", done: false), + ], + status: .working, startedAt: base), + AgentRun(companionId: "sage", deptName: "Legal", + taskTitle: "Privacy policy draft", + steps: [ExecStep(label: "Draft clauses", done: true)], + status: .done, startedAt: base), + ] + return ScrollView { + AgentsWorkingRow(runs: runs, now: Date(timeIntervalSince1970: 134)) + .frame(maxWidth: 760) + .padding(.horizontal, 24).padding(.vertical, 40) + } + .frame(width: 900, height: 700) + .background(ChatBackdrop()) + .environmentObject(CompanyStore()) + .environment(\.uiLanguage, .en) +} +#endif +``` + +- [ ] **Step 3: Build to verify it compiles and previews resolve** + +Run: +```bash +xcodebuild build -project CodePet.xcodeproj -scheme codepet \ + -configuration Debug CODE_SIGNING_ALLOWED=NO 2>&1 | tail -8 +``` +Expected: `** BUILD SUCCEEDED **` (Debug build compiles the `#if DEBUG` `#Preview` blocks). + +- [ ] **Step 4: Commit** + +```bash +git add codepet/Views/Copilot/AgentsWorkingRow.swift codepet/Views/Copilot/Mocks/AgentsWorkingMock.swift +git commit -F - <<'EOF' +feat(chat): AgentsWorkingRow — inline parallel agents view + preview + +Codex-style multi-agent exec-log: per-agent avatar, Name·Dept, status pill, +elapsed, step counter, and live checklist. Reuses ExecLogRow's step visuals. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +--- + +### Task 3: `ChatMockData` harness + `ChatUserMock` scenario + +**Files:** +- Create: `codepet/Views/Copilot/Mocks/ChatMockData.swift` +- Create: `codepet/Views/Copilot/Mocks/ChatUserMock.swift` + +**Interfaces:** +- Consumes: `CopilotMessage`, `CopilotBubble`, `ChatBackdrop`, `CompanyStore`, `FirstRunAction`. +- Produces: `enum ChatMockData` with `static func conversation(_ messages: [CopilotMessage]) -> some View`. + +- [ ] **Step 1: Create the shared harness** + +Create `codepet/Views/Copilot/Mocks/ChatMockData.swift`: + +```swift +import SwiftUI + +#if DEBUG +/// Shared renderer for the chat-scenario preview mocks. Feeds fixture messages +/// through the REAL `CopilotBubble` exactly like `CopilotChatView.messageList`, +/// so the mocks match production and cannot drift. Layout constants mirror the +/// shipped spacing pass (column 760, gutter 24, top 40 / bottom 24, spacing 24). +enum ChatMockData { + static func conversation(_ messages: [CopilotMessage]) -> some View { + ZStack { + ChatBackdrop() + ScrollView { + VStack(alignment: .leading, spacing: 24) { + ForEach(messages) { m in + CopilotBubble(message: m).id(m.id) + } + } + .padding(.top, 40) + .padding(.bottom, 24) + .frame(maxWidth: 760, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 24) + } + } + .frame(width: 900, height: 720) + .environmentObject(CompanyStore()) + .environment(\.uiLanguage, .en) + } +} +#endif +``` + +- [ ] **Step 2: Create the "with the user" scenario** + +Create `codepet/Views/Copilot/Mocks/ChatUserMock.swift`: + +```swift +import SwiftUI + +#if DEBUG +#Preview("Chat · with the user") { + ChatMockData.conversation([ + CopilotMessage(role: .me, text: "Which task should I run right now?"), + CopilotMessage(role: .companion, + text: "Your leverage right now is momentum, not polish. Ship the smallest thing a real user can touch this week: write your positioning in one sentence, book five short calls, and put a rough landing page in front of them. Want me to draft the positioning line?"), + CopilotMessage(role: .me, text: "draft it"), + CopilotMessage(role: .companion, + text: "On it — I can start this as a task and do the work with you.", + firstRunAction: FirstRunAction(taskId: "t1", + taskTitle: "Write your positioning statement")), + ]) +} +#endif +``` + +- [ ] **Step 3: Build to verify** + +Run: +```bash +xcodebuild build -project CodePet.xcodeproj -scheme codepet \ + -configuration Debug CODE_SIGNING_ALLOWED=NO 2>&1 | tail -8 +``` +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 4: Commit** + +```bash +git add codepet/Views/Copilot/Mocks/ChatMockData.swift codepet/Views/Copilot/Mocks/ChatUserMock.swift +git commit -F - <<'EOF' +feat(chat): mock harness + "with the user" scenario preview + +ChatMockData renders fixture messages through the real CopilotBubble; the +first scenario preview exercises text bubbles + the first-run action button. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +--- + +### Task 4: Roadmap, Tasks, and Environment scenario previews + +**Files:** +- Create: `codepet/Views/Copilot/Mocks/ChatRoadmapMock.swift` +- Create: `codepet/Views/Copilot/Mocks/ChatTasksMock.swift` +- Create: `codepet/Views/Copilot/Mocks/ChatEnvMock.swift` + +**Interfaces:** +- Consumes: `ChatMockData.conversation(_:)` (Task 3); `CopilotMessage`, `NavAction`, `Deliverable`, `DeliverableKind`, `ExecStep`, `SetupAction`, `RememberedFact`. + +- [ ] **Step 1: Create the roadmap scenario** + +Create `codepet/Views/Copilot/Mocks/ChatRoadmapMock.swift`: + +```swift +import SwiftUI + +#if DEBUG +#Preview("Chat · with the roadmap") { + ChatMockData.conversation([ + CopilotMessage(role: .me, text: "What's next on the roadmap?"), + CopilotMessage(role: .companion, + text: "You're in Validation. The one thing between you and launch is a working sign-up that captures real interest — everything else can wait. After that: pricing, then a small paid pilot."), + CopilotMessage(role: .companion, text: "", + navChip: NavAction(destination: "roadmap", target: nil)), + ]) +} +#endif +``` + +- [ ] **Step 2: Create the tasks scenario** + +Create `codepet/Views/Copilot/Mocks/ChatTasksMock.swift`: + +```swift +import SwiftUI + +#if DEBUG +#Preview("Chat · with tasks") { + ChatMockData.conversation([ + CopilotMessage(role: .me, text: "Run the landing page copy task."), + CopilotMessage(role: .companion, text: "Write your landing page copy", + producing: true, companionId: "nova", deptName: "Marketing", + execSteps: [ + ExecStep(label: "Reading your brief — mission, audience, voice", done: true), + ExecStep(label: "Pulling in the Marketing playbook", done: true), + ExecStep(label: "Drafting the headline and subhead", done: false), + ExecStep(label: "Matching your tone and past decisions", done: false), + ]), + CopilotMessage(role: .companion, text: "", + draft: Deliverable(kind: .post, title: "Landing page copy", + body: "Headline — Your AI cofounder, not another chatbot.\n\nSubhead — Codepet plans your next move, does the work with you, and remembers every decision — grounded in your actual company.")), + CopilotMessage(role: .companion, text: "", + draft: Deliverable(kind: .post, title: "Positioning statement", + body: "For solo founders who can code but stall on everything else, Codepet is the AI cofounder that runs the whole company with you."), + draftApproved: true), + ]) +} +#endif +``` + +- [ ] **Step 3: Create the environment scenario** + +Create `codepet/Views/Copilot/Mocks/ChatEnvMock.swift`: + +```swift +import SwiftUI + +#if DEBUG +#Preview("Chat · setting up the environment") { + ChatMockData.conversation([ + CopilotMessage(role: .me, text: "Help me set up my tools."), + CopilotMessage(role: .companion, + text: "You'll want your code and your notes connected so I can act on them. Start with GitHub — it lets me open PRs and read your repo."), + CopilotMessage(role: .companion, text: "", + setupSuggestion: SetupAction(category: "connectors", name: "GitHub")), + CopilotMessage(role: .companion, text: "", + noted: [RememberedFact(topic: "Stack", + statement: "Ships a native macOS app in Swift")]), + ]) +} +#endif +``` + +- [ ] **Step 4: Build to verify all three compile** + +Run: +```bash +xcodebuild build -project CodePet.xcodeproj -scheme codepet \ + -configuration Debug CODE_SIGNING_ALLOWED=NO 2>&1 | tail -8 +``` +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 5: Commit** + +```bash +git add codepet/Views/Copilot/Mocks/ChatRoadmapMock.swift \ + codepet/Views/Copilot/Mocks/ChatTasksMock.swift \ + codepet/Views/Copilot/Mocks/ChatEnvMock.swift +git commit -F - <<'EOF' +feat(chat): roadmap / tasks / environment scenario previews + +Three fixture-driven chat mocks exercising the nav chip, the run exec-log + +draft cards (with an approved state), and the setup card + noted chip. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +--- + +## Self-Review + +**Spec coverage:** +- 5 mock views → Tasks 2 (`AgentsWorkingMock`), 3 (`ChatUserMock`), 4 (roadmap/tasks/env). ✅ +- Mocks drive real `CopilotBubble` → `ChatMockData` harness (Task 3). ✅ +- `AgentsWorkingRow` + `AgentRun` model → Tasks 1–2. ✅ +- Four scenarios cover user / roadmap / tasks / environment with the exact states named in the spec (text + action, nav chip, exec-log + draft + approved, setup card + noted chip). ✅ +- Pure-logic tests for step/elapsed/status math → Task 1. ✅ (Color mapping is view-side and not unit-tested; the spec's "status→pill" check is realized as the exhaustive `label` test — an intentional, documented simplification since asserting `Color` equality is brittle.) +- View-layer only / no engine / no persistence → no engine files touched. ✅ +- Follow-on (live wiring, engine parallelism, dev gallery) → out of scope, recorded in the spec. ✅ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; every command has expected output. ✅ + +**Type consistency:** `AgentRun` init and members (`stepCounter`, `currentStepIndex`, `elapsedString(now:)`, `AgentRunStatus.label(_:)`) are defined in Task 1 and consumed unchanged in Task 2; `ChatMockData.conversation(_:)` defined in Task 3 and consumed in Task 4; all existing-type initializers match the verified signatures in Global Constraints. ✅ diff --git a/docs/superpowers/plans/2026-07-28-composer-chips-states.md b/docs/superpowers/plans/2026-07-28-composer-chips-states.md new file mode 100644 index 0000000..c9ca99b --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-composer-chips-states.md @@ -0,0 +1,265 @@ +# Composer chips + state expression + companion-tinted send Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Real department chips (grounding-scope), a companion-tinted send, and idle/focused/busy state on the composer — no fake affordances, no CF change. + +**Architecture:** `ChatContext.compose` gains a `focusDepartment`; `sendChat`/`sendMessage` thread a selected `Department` into it; `ChatComposer` gains a chip row + state-expressing border/opacity + companion-tinted send; `CopilotChatView` owns the selection and the companion hues. + +**Tech Stack:** SwiftUI (macOS), CodepetTheme, Xcode. + +## Global Constraints + +- Repo/branch: `My-Outcasts/codepet`, `feat/chat-redesign` (PR #39). Held. Rebase over concurrent commits before pushing. +- Build/test **FOREGROUND** only: `xcodebuild -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO`. +- Synchronized folder groups — new `.swift` auto-includes; **no `project.pbxproj` edit**. +- SourceKit "Cannot find … in scope" for these files are known FALSE POSITIVES; `xcodebuild` is authoritative. +- Baseline suite: **0 real failures**; trailing overall `** TEST FAILED **` = known `CompanyStoreScaffordOnboardingTests` Firebase-init flake (NOT a regression; wobbles the COUNT — judge by "0 failures"). +- **No CF/schema/dependency change. No fake affordances.** The `+` quick-actions menu and the `Ask ▾` mode menu stay. `department == nil` must reproduce today's behaviour exactly. +- Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) `. +- Composer look (chips, focus glow, busy dim, tinted send) is human-verified on a signed build (controller builds & launches). + +--- + +### Task 1: `ChatContext.focusDepartment` + thread through `sendChat` + +**Files:** +- Modify: `codepet/Models/ChatContext.swift` +- Modify: `codepet/Managers/CompanyStore.swift` +- Test: `codepetTests/ChatContextFocusTests.swift` + +**Interfaces:** +- Produces: `ChatContext.compose(..., focusDepartment: Department? = nil)`; `CompanyStore.sendChat(_:language:department:)`. Consumed by Task 2's wiring. + +- [ ] **Step 1: Write the failing tests** + +Create `codepetTests/ChatContextFocusTests.swift`: + +```swift +import XCTest +@testable import codepet + +final class ChatContextFocusTests: XCTestCase { + private let brief = CompanyBrief() + private let dep = DepartmentCatalog.all.first! // e.g. product/engineering + + func testFocusDirectivePresentWhenSet() { + let out = ChatContext.compose(brief: brief, tasks: [], focusDepartment: dep) + XCTAssertTrue(out.contains("focused on the \(dep.name) department"), + "focus directive should name the department") + XCTAssertTrue(out.contains(dep.focus), "focus directive should include the dept focus line") + } + + func testNoDirectiveWhenNil() { + let out = ChatContext.compose(brief: brief, tasks: [], focusDepartment: nil) + XCTAssertFalse(out.contains("focused on the")) + } + + func testNilBranchEqualsDefaultCompose() { + // Parity: passing focusDepartment nil must equal omitting it (no drift). + let a = ChatContext.compose(brief: brief, tasks: [], focusDepartment: nil) + let b = ChatContext.compose(brief: brief, tasks: []) + XCTAssertEqual(a, b) + } +} +``` + +- [ ] **Step 2: Run tests → RED** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "ChatContextFocus|error:" | tail -8` +Expected: compile failure — `compose` has no `focusDepartment:` parameter. + +- [ ] **Step 3: Add `focusDepartment` to `ChatContext.compose`** + +In `codepet/Models/ChatContext.swift`, change the `compose` signature to add the parameter (keep it defaulted so existing callers are unaffected): +```swift + static func compose(brief: CompanyBrief, tasks: [RoadmapTask], decisions: [DecisionEntry] = [], + library: [Deliverable] = [], query: String? = nil, + focusDepartment: Department? = nil) -> String { +``` +Immediately after the brief part is appended (after `parts.append(BriefContext.compose(brief) ?? "No brief yet.")`), insert: +```swift + if let dep = focusDepartment { + parts.append("The founder is focused on the \(dep.name) department right now — " + + "prioritize \(dep.name) in your answer: \(dep.focus)") + } +``` +Nothing else changes (the full department block still follows as today). + +- [ ] **Step 4: Run tests → GREEN** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "ChatContextFocusTests|Executed [0-9]+ tests" | tail -6` +Expected: the 3 ChatContextFocus tests pass; suite 0 real failures. + +- [ ] **Step 5: Thread `department` through `sendChat`/`sendMessage`** + +In `codepet/Managers/CompanyStore.swift`: +- `sendChat` (line ~307): add the param and forward it — +```swift + func sendChat(_ raw: String, language: AppLanguage, department: Department? = nil) async { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + await sendMessage(text, language: language, department: department) + } +``` +- `sendMessage` (line ~457): add `department: Department? = nil` to its signature. +- In the `ChatContext.compose(...)` call inside `sendMessage` (line ~479–480), add `focusDepartment: department` as the final argument: +```swift + context: ChatContext.compose(brief: company.brief, tasks: company.tasks, decisions: company.decisions, + library: company.library, query: text, focusDepartment: department), +``` +Do NOT change the run-task compose call (line ~727) or any other `sendMessage`/`sendChat` caller — they default to nil (today's behaviour). + +- [ ] **Step 6: Build** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 7: Commit** + +```bash +git add codepet/Models/ChatContext.swift codepet/Managers/CompanyStore.swift codepetTests/ChatContextFocusTests.swift +git commit -m "feat(chat): department-focus grounding scope (real dept chips backend) + +ChatContext.compose gains focusDepartment; sendChat/sendMessage thread a +selected Department into it so a chip genuinely refocuses the model's +grounding. nil = today's behaviour (parity test). No CF change. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: Composer chips + state expression + tinted send + wiring + +**Files:** +- Modify: `codepet/Views/Copilot/ChatComposer.swift` +- Modify: `codepet/Views/Copilot/CopilotChatView.swift` + +**Interfaces:** +- Consumes: `CompanyStore.sendChat(_:language:department:)` (Task 1), `DepartmentCatalog.all`, `PetCharacter.color`/`.secondColor`. + +- [ ] **Step 1: Add the new inputs to `ChatComposer`** + +In `ChatComposer`, add stored inputs (near the existing `var canSend`/`var placeholder`): +```swift + var accent: Color + var accent2: Color + var isBusy: Bool + @Binding var selectedDept: Department? +``` + +- [ ] **Step 2: Add the department chip row** + +Insert a chip row between the `TextField` and the control `HStack` in `body`. Add this computed view and call it (`deptChips`) right after the `TextField(...)` modifiers: + +```swift + private var deptChips: some View { + HStack(spacing: 6) { + ForEach(DepartmentCatalog.all.prefix(3)) { dep in + chip(dep) + } + Menu { + ForEach(DepartmentCatalog.all.dropFirst(3)) { dep in + Button { + selectedDept = (selectedDept?.key == dep.key) ? nil : dep + } label: { + Label(dep.name, systemImage: selectedDept?.key == dep.key ? "checkmark" : "") + } + } + } label: { + Text("•••").font(CodepetTheme.inter(12, weight: .semibold)) + .foregroundColor(CodepetTheme.mutedText) + .padding(.horizontal, 10).frame(height: 26) + .overlay(Capsule().stroke(CodepetTheme.hairline)) + } + .menuStyle(.button).buttonStyle(.plain).menuIndicator(.hidden).fixedSize() + } + } + + private func chip(_ dep: Department) -> some View { + let on = selectedDept?.key == dep.key + return Button { + selectedDept = on ? nil : dep + } label: { + Text(dep.name).font(CodepetTheme.inter(12, weight: .semibold)) + .foregroundColor(on ? dep.accent : CodepetTheme.bodyText) + .padding(.horizontal, 10).frame(height: 26) + .background(Capsule().fill(on ? dep.accent.opacity(0.15) : Color.clear)) + .overlay(Capsule().stroke(on ? dep.accent : CodepetTheme.hairline)) + }.buttonStyle(.plain) + } +``` +In `body`, the `VStack(alignment: .leading, spacing: 12)` becomes: `TextField(...)`, then `deptChips`, then the control `HStack`. + +- [ ] **Step 3: State-express the card + companion-tint the border/glow** + +Change the composer's background/overlay/shadow (currently a fixed purple→pink gradient border + always-on purple glow) to use the companion `accent` and react to focus/busy. Replace the `.overlay(...stroke(LinearGradient…))` + the two shadows with: +```swift + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(accent.opacity(focus.wrappedValue ? 0.9 : 0.5), + lineWidth: focus.wrappedValue ? 1.5 : 1.2) + ) + .codepetShadow(CodepetTheme.floatingShadow) + .shadow(color: (focus.wrappedValue && !reduceTransparency) ? accent.opacity(0.28) : .clear, radius: 18) + .opacity(isBusy ? 0.72 : 1.0) +``` +(Keep the `.background(RoundedRectangle(cornerRadius: 16).fill(CodepetTheme.surface))` as-is. `focus` is the existing `FocusState.Binding`; `reduceTransparency` is the existing env var.) + +- [ ] **Step 4: Companion-tint the send button** + +In `sendButton`, change the `canSend` fill gradient from `[CodepetTheme.accentPurple, CodepetTheme.accentPink]` to `[accent, accent2]`, and the `canSend` glow from `accentPurple.opacity(0.55)` to `accent.opacity(0.55)`. The disabled fill (`mutedText`) and everything else unchanged. + +- [ ] **Step 5: Update the `#Preview` host** + +The `ChatComposerPreviewHost` (in `ChatComposer.swift`) must supply the new inputs. Add `@State private var dept: Department? = nil` and pass `accent: CodepetTheme.accentPurple, accent2: CodepetTheme.accentPink, isBusy: false, selectedDept: $dept` to the `ChatComposer(...)` init. + +- [ ] **Step 6: Wire `CopilotChatView`** + +In `CopilotChatView`: +- Add `@State private var selectedDept: Department?`. +- Add computed hues: +```swift + private var companionAccent: Color { PetCharacter.all[companyStore.company.companionId]?.color ?? CodepetTheme.accentPurple } + private var companionAccent2: Color { PetCharacter.all[companyStore.company.companionId]?.secondColor ?? CodepetTheme.accentPink } +``` +- In `composerView`, pass the new inputs to `ChatComposer(...)`: `accent: companionAccent, accent2: companionAccent2, isBusy: companyStore.isCompanionTyping || companyStore.isStreaming, selectedDept: $selectedDept`. +- In `send()`, thread the department: change `Task { await companyStore.sendChat(text, language: lang) }` to `Task { await companyStore.sendChat(text, language: lang, department: selectedDept) }`. +- In `runQuickAction(_:)`, likewise pass `department: selectedDept` to its `sendChat` call (keeps the focus consistent for quick actions). + +- [ ] **Step 7: Build + full suite** + +Run build then test: +`xcodebuild build … CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` → `** BUILD SUCCEEDED **` +`xcodebuild test … CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "codepetTests.xctest' (passed|failed)|Executed [0-9]+ tests" | tail -3` → `codepetTests.xctest` 0 real failures. + +- [ ] **Step 8: Commit** + +```bash +git add codepet/Views/Copilot/ChatComposer.swift codepet/Views/Copilot/CopilotChatView.swift +git commit -m "feat(chat): composer department chips + focus/busy states + tinted send + +Real dept chips (first 3 + overflow) that scope the answer's grounding via +selectedDept -> sendChat(department:); border/glow follow focus in the +companion hue; card dims while busy; send fills the companion's colors. +Keeps the + quick-actions and Ask/Plan/Build menu. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Post-implementation (controller, human sign-off) + +Rebase over concurrent commits, push, build & launch signed. Founder verifies: chips render (3 + •••), toggle + tint on tap; focusing the field brightens the border to the companion hue with a glow; card dims + send greys while a reply streams; send shows the companion's colors; `+`/`Ask ▾` still work; and picking a department then asking yields a visibly department-focused reply. Watch the composer height on a small window (chips row). + +--- + +## Self-Review + +**1. Spec coverage:** grounding scope → Task 1 (compose + threading). Real chips → Task 2 Step 2 + Task 1 threading. State expression → Step 3. Companion-tinted send → Step 4. Keep `+`/mode → untouched in Step 2 (only inserts a row). Wiring/persist-selection → Step 6. Tests (directive present/absent + nil parity) → Task 1. ✓ + +**2. Placeholder scan:** Full code for the compose change, the tests, the chip row, the state modifiers, the send tint, the preview, and the wiring. No vague steps. ✓ + +**3. Type consistency:** `focusDepartment: Department?` defined Task 1, threaded via `sendChat(_:language:department:)` (Task 1) and called with `department: selectedDept` in Task 2 Step 6. `ChatComposer` new inputs (`accent`/`accent2`/`isBusy`/`selectedDept`) defined Step 1, supplied by `CopilotChatView` Step 6 and the preview Step 5. `DepartmentCatalog.all: [Department]` and `Department.{key,name,accent,focus}` confirmed present. `focus.wrappedValue`/`reduceTransparency` are existing members of `ChatComposer`. ✓ diff --git a/docs/superpowers/plans/2026-07-28-landing-state.md b/docs/superpowers/plans/2026-07-28-landing-state.md new file mode 100644 index 0000000..85a8257 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-landing-state.md @@ -0,0 +1,305 @@ +# First-run / empty chat state — live landing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** A pure `ChatLandingState` drives the empty-chat greeting + up to three live roadmap cards (beacon / needs-you / awaiting-approval), falling back to three composer-filling prompt starters when there's no roadmap. + +**Architecture:** `ChatLandingState` (pure, tested) computed from `CompanyState`; `ChatEmptyState` swaps its static `QuickAction` grid for live cards / starters driven by that state; `CopilotChatView` builds the state and supplies open-roadmap + fill-composer handlers. Composer + `+` quick-actions unchanged. + +**Tech Stack:** SwiftUI (macOS), CodepetTheme, RoadmapEngine, Xcode. + +## Global Constraints + +- Repo/branch: `My-Outcasts/codepet`, `feat/chat-redesign` (PR #39). Held. Rebase over concurrent commits before pushing. +- Build/test **FOREGROUND** only: `xcodebuild -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO`. +- Synchronized folder groups — new `.swift` auto-includes; **no `project.pbxproj` edit**. +- SourceKit "Cannot find … in scope" for these files are known FALSE POSITIVES; `xcodebuild` is authoritative. +- Baseline suite: **0 real failures**; trailing overall `** TEST FAILED **` = known `CompanyStoreScaffordOnboardingTests` Firebase-init flake (NOT a regression; wobbles the COUNT — judge by "0 failures"). +- **Greeting copy is MOVED, not rewritten** — must match today's wording exactly. The composer's `+` quick-actions menu stays. Starters INSERT into the composer (do not send). +- Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) `. +- Card look + taps are human-verified on a signed build (controller builds & launches). + +## Verified APIs + +- `RoadmapEngine.nextStep(_ tasks: [RoadmapTask]) -> RoadmapTask?`; `RoadmapEngine.status(for: RoadmapTask, in: [RoadmapTask]) -> TaskStatus`. +- `TaskStatus`: `done, needsApproval, blocked, needsYou, codepetCanDo`. +- `RoadmapTask.id: String`, `.title: String`. `CompanyState.tasks: [RoadmapTask]`, `.brief.founderName: String?`, `.brief.projectName: String?`, and `CompanyState.empty` exists. +- Nav: `companyStore.select(.roadmap)`; `companyStore.selectedDeptKey`. + +--- + +### Task 1: `ChatLandingState` (pure) + tests + +**Files:** +- Create: `codepet/Models/ChatLandingState.swift` +- Test: `codepetTests/ChatLandingStateTests.swift` + +**Interfaces:** +- Produces: `struct ChatLandingState` with `init(company:now:language:)` and `greeting/question/beacon/needsYouCount/awaitingApprovalCount/isEmpty`. Consumed by Task 2. + +- [ ] **Step 1: Write the failing tests** + +Create `codepetTests/ChatLandingStateTests.swift`: + +```swift +import XCTest +@testable import codepet + +final class ChatLandingStateTests: XCTestCase { + private func date(hour: Int) -> Date { + var c = DateComponents(); c.year = 2026; c.month = 7; c.day = 28; c.hour = hour; c.minute = 0 + return Calendar.current.date(from: c)! + } + private func company(founder: String? = "Mona", project: String? = "Acme", + tasks: [RoadmapTask] = []) -> CompanyState { + var b = CompanyBrief(); b.founderName = founder; b.projectName = project + var c = CompanyState.empty; c.brief = b; c.tasks = tasks + return c + } + + func testGreetingHourBoundaries() { + XCTAssertTrue(ChatLandingState(company: company(), now: date(hour: 11), language: .en).greeting.hasPrefix("Good morning")) + XCTAssertTrue(ChatLandingState(company: company(), now: date(hour: 12), language: .en).greeting.hasPrefix("Good afternoon")) + XCTAssertTrue(ChatLandingState(company: company(), now: date(hour: 17), language: .en).greeting.hasPrefix("Good afternoon")) + XCTAssertTrue(ChatLandingState(company: company(), now: date(hour: 18), language: .en).greeting.hasPrefix("Good evening")) + } + func testGreetingFounderNameAndFallback() { + XCTAssertEqual(ChatLandingState(company: company(founder: "Mona"), now: date(hour: 9), language: .en).greeting, "Good morning, Mona.") + XCTAssertEqual(ChatLandingState(company: company(founder: " "), now: date(hour: 9), language: .en).greeting, "Good morning, there.") + XCTAssertEqual(ChatLandingState(company: company(founder: nil), now: date(hour: 9), language: .vi).greeting, "Chào buổi sáng, bạn.") + } + func testQuestionUsesProjectWithFallback() { + XCTAssertTrue(ChatLandingState(company: company(project: "Acme"), now: date(hour: 9), language: .en).question.contains("Acme")) + XCTAssertTrue(ChatLandingState(company: company(project: " "), now: date(hour: 9), language: .en).question.contains("Codepet")) + } + func testBeaconCountsAndEmpty() { + XCTAssertTrue(ChatLandingState(company: company(tasks: []), now: date(hour: 9), language: .en).isEmpty) + // Build a small fixture; assert beacon == RoadmapEngine.nextStep and counts match the engine's status. + let tasks = SampleRoadmap.mixed() // implementer: construct a few RoadmapTasks (done / drafted / who==.you / codepetCanDo) + let s = ChatLandingState(company: company(tasks: tasks), now: date(hour: 9), language: .en) + XCTAssertEqual(s.beacon?.id, RoadmapEngine.nextStep(tasks)?.id) + XCTAssertEqual(s.awaitingApprovalCount, tasks.filter { RoadmapEngine.status(for: $0, in: tasks) == .needsApproval }.count) + XCTAssertEqual(s.needsYouCount, tasks.filter { RoadmapEngine.status(for: $0, in: tasks) == .needsYou && $0.id != s.beacon?.id }.count) + XCTAssertFalse(s.isEmpty) + } +} +``` +Implementer note: replace `SampleRoadmap.mixed()` with an inline array of a few `RoadmapTask(...)` built from the real initializer (read `RoadmapTask.swift`) — include at least one `done`, one `drafted` (→ needsApproval), and one `who == .you` not-done (→ needsYou). The assertions derive expected values from `RoadmapEngine`, so they hold for whatever fixture you build. + +- [ ] **Step 2: Run tests → RED** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "ChatLandingState|error:" | tail -8` +Expected: compile failure — `ChatLandingState` undefined. + +- [ ] **Step 3: Implement `ChatLandingState`** + +Create `codepet/Models/ChatLandingState.swift`: + +```swift +import Foundation + +/// Pure landing-state for the empty chat: greeting + the live roadmap signals +/// that drive the landing cards. Deterministic given `now`. SwiftUI-free. +struct ChatLandingState { + let greeting: String + let question: String + let beacon: RoadmapTask? + let needsYouCount: Int + let awaitingApprovalCount: Int + let isEmpty: Bool + + init(company: CompanyState, now: Date, language: AppLanguage) { + let founderRaw = (company.brief.founderName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let founder = founderRaw.isEmpty ? (language == .vi ? "bạn" : "there") : founderRaw + let hour = Calendar.current.component(.hour, from: now) + let part: String + switch hour { + case ..<12: part = language == .vi ? "Chào buổi sáng" : "Good morning" + case 12..<18: part = language == .vi ? "Chào buổi chiều" : "Good afternoon" + default: part = language == .vi ? "Chào buổi tối" : "Good evening" + } + greeting = "\(part), \(founder)." + + let projectRaw = (company.brief.projectName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let project = projectRaw.isEmpty ? "Codepet" : projectRaw + question = language == .vi ? "Hôm nay mình xây gì cho \(project)?" : "What should we build for \(project) today?" + + let tasks = company.tasks + let next = RoadmapEngine.nextStep(tasks) + beacon = next + needsYouCount = tasks.filter { RoadmapEngine.status(for: $0, in: tasks) == .needsYou && $0.id != next?.id }.count + awaitingApprovalCount = tasks.filter { RoadmapEngine.status(for: $0, in: tasks) == .needsApproval }.count + isEmpty = tasks.isEmpty + } +} +``` + +- [ ] **Step 4: Run tests → GREEN** + +Run: `xcodebuild test … CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "ChatLandingStateTests|Executed [0-9]+ tests" | tail -6` +Expected: ChatLandingState tests pass; suite 0 real failures. + +- [ ] **Step 5: Commit** + +```bash +git add codepet/Models/ChatLandingState.swift codepetTests/ChatLandingStateTests.swift +git commit -m "feat(chat): pure ChatLandingState (greeting + live roadmap signals) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: `ChatEmptyState` live landing + wiring + +**Files:** +- Modify: `codepet/Views/Copilot/ChatEmptyState.swift` +- Modify: `codepet/Views/Copilot/CopilotChatView.swift` + +**Interfaces:** +- Consumes: `ChatLandingState` (Task 1); `companyStore.select(.roadmap)`; `CodepetTheme` accents. + +- [ ] **Step 1: Re-input `ChatEmptyState`** + +Replace `line1/line2/quickActions/onQuickAction` with: +```swift + let state: ChatLandingState + let onOpenRoadmap: () -> Void + let onStarter: (String) -> Void + @ViewBuilder var composer: Composer + @Environment(\.uiLanguage) private var lang +``` +Update `greeting` to read `state.greeting` (line 1) and `state.question` (line 2) — same fonts/gradient/layout as today (just swap `line1`→`state.greeting`, `line2`→`state.question`). + +- [ ] **Step 2: Replace the `cards`/`card(_:)` with live cards + starters** + +```swift + private var cards: some View { + Group { + if state.isEmpty { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + ForEach(starters, id: \.self) { starterCard($0) } + } + } else { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + if let b = state.beacon { + landingCard(eyebrow: lang == .vi ? "TIẾP THEO" : "DO THIS NEXT", + value: b.title, hue: CodepetTheme.accentPurple, onTap: onOpenRoadmap) + } + if state.needsYouCount > 0 { + landingCard(eyebrow: lang == .vi ? "CẦN BẠN" : "NEEDS YOU", + value: "\(state.needsYouCount)", hue: CodepetTheme.accentBlue, onTap: onOpenRoadmap) + } + if state.awaitingApprovalCount > 0 { + landingCard(eyebrow: lang == .vi ? "CHỜ DUYỆT" : "AWAITING APPROVAL", + value: "\(state.awaitingApprovalCount)", hue: CodepetTheme.accentGold, onTap: onOpenRoadmap) + } + } + } + } + .frame(maxWidth: 600) + } + + private var starters: [String] { + lang == .vi + ? ["Soạn định vị của tôi", "Lên kế hoạch tuần này", "Xem lại bản tóm tắt"] + : ["Draft my positioning", "Plan this week", "Review my brief"] + } + + private func landingCard(eyebrow: String, value: String, hue: Color, onTap: @escaping () -> Void) -> some View { + Button(action: onTap) { + HStack(alignment: .top, spacing: 0) { + Capsule().fill(hue).frame(width: 3).padding(.vertical, 2) + VStack(alignment: .leading, spacing: 4) { + Text(eyebrow).font(CodepetTheme.inter(10, weight: .semibold)) + .foregroundColor(hue).tracking(0.5) + Text(value).font(CodepetTheme.inter(14, weight: .semibold)) + .foregroundColor(CodepetTheme.primaryText).lineLimit(2) + .multilineTextAlignment(.leading).fixedSize(horizontal: false, vertical: true) + }.padding(.leading, 12) + Spacer(minLength: 0) + } + .padding(.vertical, 12).padding(.trailing, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(CodepetTheme.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(CodepetTheme.hairline)) + }.buttonStyle(.plain) + } + + private func starterCard(_ text: String) -> some View { + Button { onStarter(text) } label: { + HStack(spacing: 8) { + Image(systemName: "sparkles").font(.system(size: 13)).foregroundColor(CodepetTheme.accentPurple) + Text(text).font(CodepetTheme.inter(13, weight: .semibold)) + .foregroundColor(CodepetTheme.primaryText) + .lineLimit(2).multilineTextAlignment(.leading).fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(.vertical, 12).padding(.horizontal, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(CodepetTheme.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(CodepetTheme.hairline)) + }.buttonStyle(.plain) + } +``` +Delete the old `QuickAction`-based `card(_:)` and the `quickActions` references in this file (the type stays for the composer's `+` menu — just not used here). + +- [ ] **Step 3: Update the `#Preview`** + +The `ChatEmptyState` `#Preview` must build a `ChatLandingState` fixture and the handlers: +```swift + ChatEmptyState( + state: ChatLandingState(company: .empty, now: Date(), language: .en), + onOpenRoadmap: {}, onStarter: { _ in } + ) { RoundedRectangle(cornerRadius: 16).fill(CodepetTheme.surface).frame(height: 96).frame(maxWidth: 600) } + .frame(width: 900, height: 700).background(Color.black).environmentObject(CompanyStore()) +``` +(`.empty` has no tasks → the preview shows the starter fallback, which is fine.) + +- [ ] **Step 4: Wire `CopilotChatView`** + +- In the empty branch, replace the `ChatEmptyState(line1:…, line2:…, quickActions:…, onQuickAction:…)` call with: +```swift + ChatEmptyState( + state: ChatLandingState(company: companyStore.company, now: Date(), language: lang), + onOpenRoadmap: { companyStore.selectedDeptKey = nil; companyStore.select(.roadmap) }, + onStarter: { draft = $0; inputFocused = true } + ) { composerView } +``` +- Remove `greetingLine1` and `greetingLine2` (now in `ChatLandingState`). If `founderName`/`companyName` are now unreferenced anywhere else in the file, remove them too (the build will confirm; leave them only if still used). +- Keep `quickActions` and `runQuickAction` — they still feed the composer's `+` menu via `composerView`. + +- [ ] **Step 5: Build + full suite** + +`xcodebuild build … CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` → `** BUILD SUCCEEDED **` +`xcodebuild test … CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "codepetTests.xctest' (passed|failed)|Executed [0-9]+ tests" | tail -3` → `codepetTests.xctest` 0 real failures. + +- [ ] **Step 6: Commit** + +```bash +git add codepet/Views/Copilot/ChatEmptyState.swift codepet/Views/Copilot/CopilotChatView.swift +git commit -m "feat(chat): live landing cards (beacon/needs-you/awaiting) + starters + +Empty chat now renders up to 3 live roadmap cards from ChatLandingState +(tap → Roadmap), or 3 composer-filling prompt starters when there's no +roadmap yet. Greeting moved into ChatLandingState (copy unchanged). The +composer + quick-actions menu are unchanged. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Post-implementation (controller, human sign-off) + +Rebase over concurrent commits, push, build & launch signed. Founder verifies: with a roadmap → DO THIS NEXT shows the beacon title + NEEDS YOU / AWAITING APPROVAL counts (when >0), each tapping to the Roadmap page; with no roadmap → three starter cards that fill the composer (not send); greeting reads right; light + dark. + +--- + +## Self-Review + +**1. Spec coverage:** ChatLandingState (greeting/question/beacon/counts/isEmpty) → Task 1. Live cards + omit-when-zero + tap→roadmap → Task 2 Step 2. Starter fallback that fills composer → Step 2 + Step 4 (`onStarter`). Greeting moved (copy unchanged) → Task 1 + Step 1. Composer `+` unchanged → Step 4 note. Tests (hour boundaries, founder/project fallbacks, beacon/counts/empty) → Task 1. ✓ + +**2. Placeholder scan:** Full code for `ChatLandingState`, the card/starters builders, the wiring, and the preview. The one implementer-filled bit is the test roadmap fixture (`SampleRoadmap.mixed()` → inline `RoadmapTask`s from the real init) — explicitly flagged with what it must contain, and the assertions derive expected values from `RoadmapEngine` so they can't drift. ✓ + +**3. Type consistency:** `ChatLandingState(company:now:language:)` + its fields defined Task 1, consumed in Task 2 Steps 1–4. `RoadmapEngine.nextStep`/`status(for:in:)`, `TaskStatus.needsYou/.needsApproval`, `RoadmapTask.id/.title`, `CompanyState.empty/.tasks/.brief`, `companyStore.select(.roadmap)` all confirmed present. `onOpenRoadmap`/`onStarter` closures match the wiring. ✓ diff --git a/docs/superpowers/plans/2026-07-28-luminous-orb-streaming.md b/docs/superpowers/plans/2026-07-28-luminous-orb-streaming.md new file mode 100644 index 0000000..acadb03 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-luminous-orb-streaming.md @@ -0,0 +1,469 @@ +# Luminous orb + streaming affordance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Turn `CompanionOrb` into a luminous, companion-tinted sphere that breathes while working, and replace the static chat "Thinking…" text with a `ChatThinkingRow` that names the work and shimmers. + +**Architecture:** Additive, layout/rendering only. Add a per-companion second hue (§1) + two theme tokens (§2), reimplement `CompanionOrb` as a `TimelineView`-driven ZStack of gradient layers (§3), a pure `ChatThinkingLabel` (§4), and a `ChatThinkingRow` that replaces `typingRow`/`producingRow` (§5). No Cloud Function / RoadmapEngine / Firestore / schema changes. + +**Tech Stack:** SwiftUI (macOS), CodepetTheme (`Color.dyn`), Xcode `xcodebuild`. + +## Global Constraints + +- Repo/branch: `My-Outcasts/codepet`, `feat/chat-redesign` (PR #39). Nothing merges — branch held. +- Build/test in the **FOREGROUND** (never background `xcodebuild`): `xcodebuild -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO`. +- Xcode **synchronized folder groups** — new `.swift` files in `codepet/` / `codepetTests/` auto-include; **no `project.pbxproj` edit**. +- SourceKit shows false-positive "Cannot find CodepetTheme/CompanyStore/… in scope" for these files — ignore; `xcodebuild build` is authoritative. +- Baseline suite: **303 real passes / 0 failures**; the trailing overall `** TEST FAILED **` is the known `CompanyStoreScaffordOnboardingTests` Firebase-init flake (fixed on PR #40) — NOT a regression. +- No literal hex in views **except** inside `CompanionOrb` (its core art) and the two `CodepetTheme` tokens. All other colours via `CodepetTheme`. +- All user-facing strings localized **en + vi** (match existing `lang == .vi ? … : …` pattern). +- Respect `accessibilityReduceMotion` (orb + shimmer) and `accessibilityReduceTransparency` (bloom). +- Commit trailer on every commit: `Co-Authored-By: Claude Opus 4.8 (1M context) `. +- Orb visual fidelity + 60fps + Reduce-Motion are **human-verified on a signed build** (no agent can see a screen). Each task's automated gate is the foreground build; Tasks 2 & 4 also run the suite. + +--- + +### Task 1: Per-companion second hue + theme tokens + +Data-only additions the orb depends on. No behaviour change on its own. + +**Files:** +- Modify: `codepet/Models/Character.swift` +- Modify: `codepet/Views/CodepetTheme.swift` + +**Interfaces:** +- Produces: `PetCharacter.secondHexColor: String` + `PetCharacter.secondColor: Color`; `CodepetTheme.chatCanvas`, `CodepetTheme.chatOrbCore`. Consumed by Task 3. + +- [ ] **Step 1: Add the second-hue property to `PetCharacter`** + +In `codepet/Models/Character.swift`: directly after the existing `let hexColor: String` stored property, add: + +```swift + let secondHexColor: String +``` + +And near the existing `var color` computed property, add: + +```swift + var secondColor: Color { Color(hex: secondHexColor) } +``` + +- [ ] **Step 2: Add `secondHexColor:` to all seven entries in `static let all`** + +Each entry is a `PetCharacter(… color: Color(hex: "#…"), hexColor: "#…", …)`. Add a `secondHexColor:` argument (immediately after the `hexColor:` argument) to each, using this table: + +| id | secondHexColor | +|---|---| +| byte | `#4EC9D4` | +| nova | `#6EA8FF` | +| crash | `#F0A860` | +| luna | `#C99BF0` | +| sage | `#FDC352` | +| glitch | `#5AD0E0` | +| null | `#5AD0E0` | + +Example for `byte` (match the surrounding formatting): + +```swift + color: Color(hex: "#8B7BE8"), hexColor: "#8B7BE8", secondHexColor: "#4EC9D4", +``` + +- [ ] **Step 3: Add the two theme tokens** + +In `codepet/Views/CodepetTheme.swift`, immediately after the accent block (after `static let accentBlue …`), add: + +```swift + static let chatCanvas = Color.dyn("#f8f7f3", "#16130f") // matches pageBackground + static let chatOrbCore = Color.dyn("#0E0A16", "#040208") // the orb's luminous core +``` + +- [ ] **Step 4: Build (the test cycle for this data task)** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` +Expected: `** BUILD SUCCEEDED **` (a missing `secondHexColor:` on any of the 7 entries fails the build — fix until green). + +- [ ] **Step 5: Commit** + +```bash +git add codepet/Models/Character.swift codepet/Views/CodepetTheme.swift +git commit -m "feat(chat): per-companion second hue + chatCanvas/chatOrbCore tokens + +Adds PetCharacter.secondHexColor (+ secondColor) for all seven companions +and the chatCanvas/chatOrbCore Color.dyn tokens the luminous orb needs. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: `ChatThinkingLabel` (pure) + tests + +Pure, testable label logic. Independent of the other tasks. TDD. + +**Files:** +- Create: `codepet/Models/ChatThinkingLabel.swift` +- Test: `codepetTests/ChatThinkingLabelTests.swift` + +**Interfaces:** +- Produces: `enum ChatThinkingLabel { static func text(taskTitle: String?, language: AppLanguage) -> String }`. Consumed by Task 4. +- Consumes: existing `AppLanguage` enum (cases `.en` / `.vi` — confirm the exact non-vi case name from any existing file that switches on `AppLanguage`; the codebase uses `lang == .vi ? … : …`, so treat non-`.vi` as English). + +- [ ] **Step 1: Write the failing test** + +Create `codepetTests/ChatThinkingLabelTests.swift`: + +```swift +import XCTest +@testable import codepet + +final class ChatThinkingLabelTests: XCTestCase { + func testNoTaskEnglish() { + XCTAssertEqual(ChatThinkingLabel.text(taskTitle: nil, language: .en), "Working on it…") + } + func testNoTaskVietnamese() { + XCTAssertEqual(ChatThinkingLabel.text(taskTitle: nil, language: .vi), "Đang xử lý…") + } + func testNamedTaskEnglish() { + XCTAssertEqual(ChatThinkingLabel.text(taskTitle: "positioning brief", language: .en), + "Drafting positioning brief…") + } + func testNamedTaskVietnamese() { + XCTAssertEqual(ChatThinkingLabel.text(taskTitle: "positioning brief", language: .vi), + "Đang soạn positioning brief…") + } + func testBlankTitleTreatedAsNone() { + XCTAssertEqual(ChatThinkingLabel.text(taskTitle: " ", language: .en), "Working on it…") + } +} +``` + +If `AppLanguage`'s English case is not `.en`, use the correct case (e.g. `.english`) consistently in the tests and implementation. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "ChatThinkingLabel|error:" | tail -8` +Expected: compile failure — `ChatThinkingLabel` is undefined. + +- [ ] **Step 3: Implement `ChatThinkingLabel`** + +Create `codepet/Models/ChatThinkingLabel.swift`: + +```swift +import Foundation + +/// The streaming-state label copy. Pure + localized. Names the in-flight work +/// when a real title exists; otherwise a generic, honest verb — never fabricate +/// a task name. +enum ChatThinkingLabel { + static func text(taskTitle: String?, language: AppLanguage) -> String { + let title = taskTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + if let title, !title.isEmpty { + return language == .vi ? "Đang soạn \(title)…" : "Drafting \(title)…" + } + return language == .vi ? "Đang xử lý…" : "Working on it…" + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "ChatThinkingLabelTests|Executed [0-9]+ tests" | tail -6` +Expected: the 5 `ChatThinkingLabelTests` pass; the `codepetTests.xctest` line shows 0 real failures (known Firebase flake aside). + +- [ ] **Step 5: Commit** + +```bash +git add codepet/Models/ChatThinkingLabel.swift codepetTests/ChatThinkingLabelTests.swift +git commit -m "feat(chat): pure ChatThinkingLabel (names the work, honest fallback) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 3: Reimplement `CompanionOrb` as a luminous, companion-tinted sphere + +The schedule-risk task. Keep the type name + `size`/`glow` API; add `isWorking`; read companion hues from the store. + +**Files:** +- Modify (replace body): `codepet/Views/Copilot/CompanionOrb.swift` +- Modify (preview only): `codepet/Views/Copilot/ChatEmptyState.swift` (inject a store into its `#Preview`) + +**Interfaces:** +- Consumes: `PetCharacter.color` / `.secondColor` (Task 1), `CodepetTheme.chatOrbCore` (Task 1), `CompanyStore` from the environment. +- Produces: `CompanionOrb(size:glow:isWorking:)` — `isWorking: true` drives a 3.6s breathe. Consumed by Task 4. The existing call sites (`ChatEmptyState` hero 78; `CopilotChatView` avatars 28) keep working unchanged (default `isWorking: false`). + +- [ ] **Step 1: Replace `CompanionOrb.swift` with the luminous implementation** + +Replace the entire body of `codepet/Views/Copilot/CompanionOrb.swift` with: + +```swift +import SwiftUI + +/// A luminous, companion-tinted sphere — the companion's identity in chat +/// (hero focal, message avatar, thinking indicator). Reads the active companion's +/// two hues from the store so switching companion re-tints every orb. Pure +/// SwiftUI, no assets. Only `isWorking` changes scale (a slow breathe); at rest +/// the internal colour drifts. Reduce Motion → one static frame. +struct CompanionOrb: View { + var size: CGFloat = 78 + var glow: Bool = true + var isWorking: Bool = false + + @EnvironmentObject var companyStore: CompanyStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + private var character: PetCharacter? { PetCharacter.all[companyStore.company.companionId] } + private var hue1: Color { character?.color ?? CodepetTheme.accentPurple } + private var hue2: Color { character?.secondColor ?? CodepetTheme.accentPink } + + var body: some View { + Group { + if reduceMotion { + orb(t: 0) + } else { + TimelineView(.animation) { tl in + orb(t: tl.date.timeIntervalSinceReferenceDate) + } + } + } + .frame(width: size, height: size) + } + + private func breathe(_ t: Double) -> CGFloat { + guard isWorking && !reduceMotion else { return 1.0 } + // 3.6s period, scale 1.0 … 1.07 + return 1.0 + 0.035 * (1 + CGFloat(sin(t * (2 * .pi / 3.6)))) + } + + private func orb(t: Double) -> some View { + ZStack { + // 1. near-black luminous core — makes colour read as emitted light + Circle().fill(RadialGradient( + gradient: Gradient(colors: [CodepetTheme.chatOrbCore, .black]), + center: .center, startRadius: 0, endRadius: size * 0.5)) + + // 2. three internal colour bands, drifting on different periods, additive + band(hue1, degPerSec: 30, t: t) + band(hue2, degPerSec: 42, t: t) + band(.white.opacity(0.5), degPerSec: 22, t: t) // hue1 "lifted toward white" + + // 3. specular crescent + Circle().fill(RadialGradient( + gradient: Gradient(colors: [.white.opacity(0.9), .clear]), + center: UnitPoint(x: 0.34 + 0.015 * sin(t * 0.6), y: 0.30), + startRadius: 0, endRadius: size * 0.30)) + + // 4. base shading for sphericality + Circle().fill(RadialGradient( + gradient: Gradient(colors: [.clear, .black.opacity(0.40)]), + center: UnitPoint(x: 0.70, y: 0.80), + startRadius: size * 0.15, endRadius: size * 0.60)) + + // 5. rim, brightest near the specular + Circle().strokeBorder( + AngularGradient(gradient: Gradient(colors: [ + .white.opacity(0.55), .clear, .clear, .white.opacity(0.2), .white.opacity(0.55)]), + center: .center), + lineWidth: max(1, size * 0.02)) + } + .compositingGroup() + .clipShape(Circle()) + .scaleEffect(breathe(t)) + .background(bloom) // 6. outer bloom, behind and unclipped + } + + private func band(_ color: Color, degPerSec: Double, t: Double) -> some View { + AngularGradient( + gradient: Gradient(colors: [color.opacity(0.0), color.opacity(0.6), color.opacity(0.0)]), + center: .center, + angle: .degrees(reduceMotion ? 0 : t * degPerSec)) + .clipShape(Circle()) + .blendMode(.plusLighter) + } + + private var bloom: some View { + Circle() + .fill(hue1.opacity((glow && !reduceTransparency) ? 0.45 : 0.0)) + .blur(radius: size * 0.45) + .scaleEffect(1.12) + } +} + +#if DEBUG +#Preview("CompanionOrb") { + HStack(spacing: 24) { + CompanionOrb(size: 78, isWorking: true) + CompanionOrb(size: 28, glow: false) + } + .padding(40) + .background(Color.black) + .environmentObject(CompanyStore()) +} +#endif +``` + +Note (implementer latitude): this is a real, compile-first implementation. If any SwiftUI signature needs adjusting for your SDK, fix it to compile while preserving the ordered composition, the `isWorking`-only breathe, and the Reduce-Motion static path. Do NOT switch to Canvas/Metal unless the ZStack cannot hold 60fps at size 78 on the signed build — that judgement happens at visual verification, not here. + +- [ ] **Step 2: Fix the `ChatEmptyState` preview to inject a store** + +`CompanionOrb` now reads `CompanyStore` from the environment, so `ChatEmptyState`'s `#Preview` must provide one. In `codepet/Views/Copilot/ChatEmptyState.swift`, find the `#Preview("ChatEmptyState")` block and add `.environmentObject(CompanyStore())` to its returned view (alongside the existing `.frame`/`.background` modifiers). + +- [ ] **Step 3: Build** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` +Expected: `** BUILD SUCCEEDED **`. (If a call site errors because the orb needs the store, confirm every runtime call site is inside a view that has `CompanyStore` in its environment — all current ones do; only previews need the explicit inject.) + +- [ ] **Step 4: Commit** + +```bash +git add codepet/Views/Copilot/CompanionOrb.swift codepet/Views/Copilot/ChatEmptyState.swift +git commit -m "feat(chat): luminous companion-tinted orb with working-breathe + +Reimplement CompanionOrb as a TimelineView-driven layered sphere (near-black +core, drifting colour bands, specular, rim, bloom), tinted from the active +companion's two hues. isWorking drives a 3.6s breathe; Reduce Motion renders +one static frame. API (size/glow) unchanged; adds isWorking. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 4: `ChatThinkingRow` + wire it in (replace `typingRow` / `producingRow`) + +**Files:** +- Create: `codepet/Views/Copilot/ChatThinkingRow.swift` +- Modify: `codepet/Views/Copilot/CopilotChatView.swift` (replace `typingRow`; route `producingRow` through the new row; delete the two old row bodies) + +**Interfaces:** +- Consumes: `CompanionOrb(size:glow:isWorking:)` (Task 3), `ChatThinkingLabel.text(taskTitle:language:)` (Task 2), the `\.uiLanguage` environment. +- Produces: `ChatThinkingRow(taskTitle: String?)`. + +- [ ] **Step 1: Create `ChatThinkingRow`** + +Create `codepet/Views/Copilot/ChatThinkingRow.swift`: + +```swift +import SwiftUI + +/// The streaming/producing state: a breathing companion orb + a label that names +/// the work (via ChatThinkingLabel), with a subtle light sweep through the text. +/// Replaces the old static typingRow/producingRow. Reduce Motion → orb static + +/// no sweep. +struct ChatThinkingRow: View { + /// A real in-flight title, or nil for a plain chat turn (→ "Working on it…"). + var taskTitle: String? = nil + + @Environment(\.uiLanguage) private var lang + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var label: String { ChatThinkingLabel.text(taskTitle: taskTitle, language: lang) } + + var body: some View { + HStack(spacing: 10) { + CompanionOrb(size: 28, glow: false, isWorking: true) + shimmerLabel + Spacer(minLength: 24) + } + } + + @ViewBuilder private var shimmerLabel: some View { + let base = Text(label).font(CodepetTheme.inter(13)).foregroundColor(CodepetTheme.mutedText) + if reduceMotion { + base + } else { + TimelineView(.animation) { tl in + let phase = tl.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.1) / 2.1 + base.overlay( + LinearGradient( + gradient: Gradient(colors: [.clear, CodepetTheme.primaryText.opacity(0.7), .clear]), + startPoint: .leading, endPoint: .trailing) + .frame(width: 60) + .offset(x: CGFloat(phase * 260 - 60)) + .mask(base) + .allowsHitTesting(false) + ) + } + } + } +} + +#if DEBUG +#Preview("ChatThinkingRow") { + VStack(alignment: .leading, spacing: 16) { + ChatThinkingRow(taskTitle: nil) + ChatThinkingRow(taskTitle: "positioning brief") + } + .padding(40) + .environmentObject(CompanyStore()) +} +#endif +``` + +- [ ] **Step 2: Wire the chat-turn typing state** + +In `codepet/Views/Copilot/CopilotChatView.swift`: the `messageList` appends `typingRow` when `companyStore.isCompanionTyping`. Replace that usage with `ChatThinkingRow(taskTitle: nil)` (keep the same `.id("typing")` on it so the existing auto-scroll `proxy.scrollTo("typing", …)` still works), and **delete** the `private var typingRow` computed property. + +Concretely, the block that today reads: +```swift + if companyStore.isCompanionTyping { typingRow.id("typing") } +``` +becomes: +```swift + if companyStore.isCompanionTyping { ChatThinkingRow(taskTitle: nil).id("typing") } +``` +and delete the `typingRow` property (lines defining `private var typingRow: some View { … }`). + +- [ ] **Step 3: Wire the producing state** + +In `CopilotBubble` (same file), `producingRow` renders when `message.producing`. Replace the `producingRow` body so it returns a `ChatThinkingRow`, passing a title only if the producing message already carries one. Inspect `CopilotMessage` for an existing title-bearing field on a producing message (e.g. a draft/run title). If one exists, pass it; if not, pass `nil`. Do NOT add new model fields or fabricate a title in this task. + +Replace the `producingRow` computed property with: +```swift + private var producingRow: some View { + // Pass a real title if the producing message carries one; else nil → "Working on it…". + ChatThinkingRow(taskTitle: nil) + } +``` +(If you find a title field on the message during inspection, substitute it for `nil` and note it in your report.) + +- [ ] **Step 4: Build** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` +Expected: `** BUILD SUCCEEDED **`. Fix any dangling reference left by deleting `typingRow`/`producingRow`. + +- [ ] **Step 5: Run the full suite (regression)** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "codepetTests.xctest' (passed|failed)|Executed [0-9]+ tests" | tail -3` +Expected: `codepetTests.xctest` shows `Executed tests, with 0 failures` (N = 303 + the new ChatThinkingLabel tests). Trailing overall `** TEST FAILED **` from the known Firebase flake is acceptable. + +- [ ] **Step 6: Commit** + +```bash +git add codepet/Views/Copilot/ChatThinkingRow.swift codepet/Views/Copilot/CopilotChatView.swift +git commit -m "feat(chat): ChatThinkingRow replaces static typing/producing text + +Breathing orb + a label that names the work (ChatThinkingLabel) with a +subtle 2.1s light-sweep; Reduce Motion → static. Replaces typingRow and +producingRow. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Post-implementation (human, out of scope for coded steps) + +Build & launch signed (I do this, as before): quit any stale instance, `xcodebuild build … -allowProvisioningUpdates` (signed), `open` the fixed `.app`. Founder verifies **on screen**: orb reads as luminous & companion-tinted (byte violet→teal), breathes only while a reply/produce is in flight, shimmer is subtle, Reduce Motion stills it, hero (78) is smooth. If the orb stutters at 60fps, fall back to Canvas/Metal per the spec (new task). + +--- + +## Self-Review + +**1. Spec coverage:** §1 second hue → Task 1 Steps 1–2. §2 tokens → Task 1 Step 3. §4 ChatThinkingLabel → Task 2. §3 luminous orb (core/bands/specular/shading/rim/bloom, isWorking breathe, reduce-motion static, companion hues) → Task 3 Step 1. §5 ChatThinkingRow + typing/producing wiring + shimmer + reduce-motion → Task 4. Testing (ChatThinkingLabelTests; build gate; suite; signed visual) → Task 2 + each build step + Post-implementation. ✓ + +**2. Placeholder scan:** No TBD/vague steps; every code step ships complete code. The producing-title source is an explicit inspect-and-decide with a concrete honest fallback (`nil`), not a gap. ✓ + +**3. Type consistency:** `CompanionOrb(size:glow:isWorking:)` defined Task 3, used Task 4. `ChatThinkingLabel.text(taskTitle:language:)` defined Task 2, used Task 4 (via `ChatThinkingRow`) — signature matches. `secondColor`/`chatOrbCore` defined Task 1, used Task 3. `.id("typing")` preserved so existing auto-scroll keeps working. `AppLanguage` English case to be confirmed against the codebase in Task 2 and used consistently. ✓ diff --git a/docs/superpowers/plans/2026-07-28-message-rendering-feedback.md b/docs/superpowers/plans/2026-07-28-message-rendering-feedback.md new file mode 100644 index 0000000..0b8b3d5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-message-rendering-feedback.md @@ -0,0 +1,271 @@ +# Message rendering (un-bubble) + per-message feedback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Un-bubble assistant messages (text on canvas beside the orb), give the user's purple bubble an asymmetric tail, and add per-message thumb up/down that writes a `MessageFeedback` doc to the existing `feedback` collection (Copy + Regenerate unchanged). + +**Architecture:** A pure `MessageFeedback` payload (+ tests); a small fire-and-forget `CompanyStore.reactToMessage` write; and restyle + additive thumbs in `CopilotBubble`. No `CopilotMessage`/schema/CF change. + +**Tech Stack:** SwiftUI (macOS), Firebase (Firestore/Auth), CodepetTheme, Xcode. + +## Global Constraints + +- Repo/branch: `My-Outcasts/codepet`, `feat/chat-redesign` (PR #39). Held. Rebase over concurrent commits before pushing. +- Build/test **FOREGROUND** only: `xcodebuild -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO`. +- Synchronized folder groups — new `.swift` in `codepet/` / `codepetTests/` auto-include; **no `project.pbxproj` edit**. +- SourceKit "Cannot find … in scope" for these files are known FALSE POSITIVES; `xcodebuild` is authoritative. +- Baseline suite: **0 real failures**; trailing overall `** TEST FAILED **` = known `CompanyStoreScaffordOnboardingTests` Firebase-init flake (NOT a regression; wobbles the test COUNT — judge by "0 failures"). +- **Copy + Regenerate closures stay byte-identical.** Thumbs are additive. No `CopilotMessage` field added. +- The `feedback` write is **create-only** (matches deployed rules) and **guarded** (`!AppEnvironment.isRunningTests && !ServerLoggingGate.isOptedOut`) — never write under XCTest. +- Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) `. +- Un-bubble/radius look + a real reaction write are human-verified on a signed build (controller builds & launches). + +--- + +### Task 1: `MessageFeedback` (pure) + tests + +**Files:** +- Create: `codepet/Models/MessageFeedback.swift` +- Test: `codepetTests/MessageFeedbackTests.swift` + +**Interfaces:** +- Produces: `struct MessageFeedback` with `firestoreData() -> [String: Any]`. Consumed by Task 2's `reactToMessage`. + +- [ ] **Step 1: Write the failing tests** + +Create `codepetTests/MessageFeedbackTests.swift`: + +```swift +import XCTest +@testable import codepet + +final class MessageFeedbackTests: XCTestCase { + private func fixture(helpful: Bool) -> MessageFeedback { + MessageFeedback(messageId: "m123", helpful: helpful, companyId: "c1", + userId: "u1", companionId: "byte") + } + + func testDataHelpfulTrue() { + let d = fixture(helpful: true).firestoreData() + XCTAssertEqual(d["kind"] as? String, "chat_message") + XCTAssertEqual(d["messageId"] as? String, "m123") + XCTAssertEqual(d["helpful"] as? Bool, true) + XCTAssertEqual(d["companyId"] as? String, "c1") + XCTAssertEqual(d["userId"] as? String, "u1") + XCTAssertEqual(d["companionId"] as? String, "byte") + XCTAssertEqual(d["platform"] as? String, "macos") + } + + func testDataHelpfulFalse() { + XCTAssertEqual(fixture(helpful: false).firestoreData()["helpful"] as? Bool, false) + } + + func testNoTimestampKey() { + // The server timestamp is added by the writer, not the payload. + XCTAssertNil(fixture(helpful: true).firestoreData()["timestamp"]) + } +} +``` + +- [ ] **Step 2: Run tests → RED** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "MessageFeedback|error:" | tail -8` +Expected: compile failure — `MessageFeedback` undefined. + +- [ ] **Step 3: Implement `MessageFeedback`** + +Create `codepet/Models/MessageFeedback.swift`: + +```swift +import Foundation + +/// A per-message reaction (thumb up/down) recorded to the `feedback` collection. +/// Pure — the writer (`CompanyStore.reactToMessage`) adds the server timestamp. +/// `kind: "chat_message"` distinguishes these from FeatureFeedbackManager's +/// feature-rating docs. +struct MessageFeedback: Equatable { + let messageId: String + let helpful: Bool + let companyId: String + let userId: String + let companionId: String + + func firestoreData() -> [String: Any] { + [ + "kind": "chat_message", + "messageId": messageId, + "helpful": helpful, + "companyId": companyId, + "userId": userId, + "companionId": companionId, + "platform": "macos", + ] + } +} +``` + +- [ ] **Step 4: Run tests → GREEN** + +Run: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "MessageFeedbackTests|Executed [0-9]+ tests" | tail -6` +Expected: the 3 MessageFeedback tests pass; suite 0 real failures. + +- [ ] **Step 5: Commit** + +```bash +git add codepet/Models/MessageFeedback.swift codepetTests/MessageFeedbackTests.swift +git commit -m "feat(chat): pure MessageFeedback payload for per-message reactions + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: `reactToMessage` write + `CopilotBubble` §9 UI + +**Files:** +- Modify: `codepet/Managers/CompanyStore.swift` (add `reactToMessage`; imports if needed) +- Modify: `codepet/Views/Copilot/CopilotChatView.swift` (`textBubble` me + companion; `companionActions` + `reaction` state) + +**Interfaces:** +- Consumes: `MessageFeedback` (Task 1); `CompanyStore.companyId` + `company.companionId` (existing); `Firestore`/`Auth`. +- Produces: `CompanyStore.reactToMessage(messageId:helpful:)`. + +- [ ] **Step 1: Add the store write method** + +In `codepet/Managers/CompanyStore.swift`, ensure `import FirebaseFirestore` and `import FirebaseAuth` are present (add if missing), and add this method to the class (near the other chat methods): + +```swift + /// Record a per-message thumb up/down to the `feedback` collection. Fire-and- + /// forget, create-only, guarded like FeatureFeedbackManager — never writes + /// under XCTest or when server logging is opted out. + func reactToMessage(messageId: String, helpful: Bool) { + guard !AppEnvironment.isRunningTests, !ServerLoggingGate.isOptedOut else { return } + var data = MessageFeedback( + messageId: messageId, helpful: helpful, + companyId: companyId, userId: Auth.auth().currentUser?.uid ?? "anonymous", + companionId: company.companionId + ).firestoreData() + data["timestamp"] = FieldValue.serverTimestamp() + Firestore.firestore().collection("feedback").addDocument(data: data) { error in + if let error { print("[Feedback] chat reaction error: \(error.localizedDescription)") } + } + } +``` + +Confirm the store exposes `companyId` (it is used in `sendChat`, e.g. `companyId: companyId`) and `company.companionId`; use those exact accessors. If `companyId` is named differently, use the store's actual current-company id accessor. + +- [ ] **Step 2: Build (verify the store method compiles)** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 3: User bubble — asymmetric tail** + +In `CopilotChatView.swift` `textBubble`, the `isMe` branch: replace the symmetric background shape. Change: +```swift + .background(RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(CodepetTheme.accentPurple)) +``` +to: +```swift + .background(UnevenRoundedRectangle( + cornerRadii: .init(topLeading: 14, bottomLeading: 14, + bottomTrailing: 4, topTrailing: 14), + style: .continuous).fill(CodepetTheme.accentPurple)) +``` +(Keep the `.padding`, `.foregroundColor(.white)`, `.font`, `.fixedSize`, and the surrounding `HStack { Spacer(minLength: 24); … }` unchanged.) + +- [ ] **Step 4: Assistant message — un-bubble** + +In the `else` (companion) branch, remove the surface background and box padding from the companion `Text` so it sits on the canvas. Change: +```swift + Text(message.text) + .font(CodepetTheme.inter(15)) + .foregroundColor(CodepetTheme.primaryText) + .padding(.horizontal, 10).padding(.vertical, 7) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(CodepetTheme.surface)) + .fixedSize(horizontal: false, vertical: true) +``` +to: +```swift + Text(message.text) + .font(CodepetTheme.inter(15)) + .foregroundColor(CodepetTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) +``` +(Keep the leading `CompanionOrb(size: 28, glow: false)`, the `HStack(alignment: .top, spacing: 10)`, the `VStack(alignment: .leading, spacing: 6)`, `companionActions`, and the trailing `Spacer(minLength: 24)` unchanged.) + +- [ ] **Step 5: Add the `reaction` state + thumb buttons** + +In `struct CopilotBubble`, add near the other `@State`: +```swift + @State private var reaction: Bool? // nil = none, true = up, false = down +``` + +Replace `companionActions` with (Copy + Regenerate unchanged; two thumbs added): +```swift + private var companionActions: some View { + HStack(spacing: 14) { + Button { copyText() } label: { + Image(systemName: "doc.on.doc").font(.system(size: 13)).foregroundColor(CodepetTheme.mutedText) + }.buttonStyle(.plain) + Button { regenerate() } label: { + Image(systemName: "arrow.clockwise").font(.system(size: 13)).foregroundColor(CodepetTheme.mutedText) + }.buttonStyle(.plain) + .disabled(companyStore.isCompanionTyping || companyStore.isStreaming) + Button { react(true) } label: { + Image(systemName: reaction == true ? "hand.thumbsup.fill" : "hand.thumbsup") + .font(.system(size: 13)) + .foregroundColor(reaction == true ? CodepetTheme.accentPurple : CodepetTheme.mutedText) + }.buttonStyle(.plain) + Button { react(false) } label: { + Image(systemName: reaction == false ? "hand.thumbsdown.fill" : "hand.thumbsdown") + .font(.system(size: 13)) + .foregroundColor(reaction == false ? CodepetTheme.accentPurple : CodepetTheme.mutedText) + }.buttonStyle(.plain) + } + } + + private func react(_ helpful: Bool) { + reaction = helpful + companyStore.reactToMessage(messageId: message.id, helpful: helpful) + } +``` +(`copyText()` and `regenerate()` stay exactly as they are.) + +- [ ] **Step 6: Build + full suite** + +Run: `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` → `** BUILD SUCCEEDED **`. +Then: `xcodebuild test -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "codepetTests.xctest' (passed|failed)|Executed [0-9]+ tests" | tail -3` +Expected: `codepetTests.xctest` shows 0 real failures. + +- [ ] **Step 7: Commit** + +```bash +git add codepet/Managers/CompanyStore.swift codepet/Views/Copilot/CopilotChatView.swift +git commit -m "feat(chat): un-bubble assistant messages + per-message thumbs + +Assistant replies drop the surface bubble and sit on the canvas beside the +orb; the user bubble keeps its purple fill with an asymmetric tail. Adds +thumb up/down (view-local selection) writing a MessageFeedback doc via +CompanyStore.reactToMessage. Copy + Regenerate unchanged. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Post-implementation (controller, human sign-off) + +Rebase over concurrent commits, push, build & launch signed (quit stale by pid, `-allowProvisioningUpdates`, `open`). Founder verifies: assistant replies read as open text beside the orb (no box); user bubble is purple with the tight bottom-right corner; thumbs show on assistant messages, fill on tap, Copy/Regenerate still work; text legible in light + dark. + +--- + +## Self-Review + +**1. Spec coverage:** §9 un-bubble assistant → Task 2 Step 4. User-bubble tail → Step 3. Thumbs + reaction state → Step 5. Feedback write → Step 1 + Task 1 payload. Tests (payload data shape, both helpful values, no timestamp key) → Task 1. Build/suite/signed visual → Task 2 + post. ✓ + +**2. Placeholder scan:** Full code for `MessageFeedback`, tests, `reactToMessage`, and every UI edit (before→after). The only conditional is "confirm `companyId` accessor name" — a named verification, not a vague gap. ✓ + +**3. Type consistency:** `MessageFeedback(messageId:helpful:companyId:userId:companionId:).firestoreData()` defined Task 1, called identically in `reactToMessage` Task 2. `reactToMessage(messageId:helpful:)` defined Step 1, called in `react(_:)` Step 5. `reaction: Bool?` drives the thumb fill. Copy/Regenerate (`copyText`/`regenerate`) untouched. `UnevenRoundedRectangle` is macOS 13+ (app deploys higher). ✓ diff --git a/docs/superpowers/plans/2026-07-28-parallel-agents-fanout.md b/docs/superpowers/plans/2026-07-28-parallel-agents-fanout.md new file mode 100644 index 0000000..0ef326f --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-parallel-agents-fanout.md @@ -0,0 +1,448 @@ +# Parallel Department Agents — Live Fan-Out — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One tap ("Run my next moves") fans a prompt out to up to 3 department agents that run in parallel, shown live in chat via the existing `AgentsWorkingRow`, with each agent's draft landing in the transcript as it finishes. + +**Architecture:** A pure client-side planner (`RoadmapEngine.nextMoves`) picks the next `codepetCanDo` task per department; `CompanyStore.fanOutNextMoves` seeds a published `[AgentRun]` and fans out concurrent `RunTaskClient` calls via a `TaskGroup` (the network layer is already stateless/parallel-safe); `CopilotChatView` adds the trigger chip and renders `AgentsWorkingRow` live. No Cloud Function changes. + +**Tech Stack:** Swift, SwiftUI, XCTest. macOS target/scheme `codepet` (lowercase). `SWIFT_VERSION = 5.0`, `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` — so a `TaskGroup` awaiting `taskRunner` yields real parallel network I/O without `Sendable` gymnastics. + +## Global Constraints + +- Reuse existing symbols — do NOT redefine: `AgentRun`/`AgentRunStatus` (`codepet/Views/Copilot/AgentsWorkingRow.swift`), `RoadmapTask`, `TaskStatus`, `RoadmapEngine.status(for:in:)`, `RoadmapPhase.order`, `DepartmentCompanions.companionId(for:)`, `DepartmentCatalog.find(_:)`, `ExecStep`, `CopilotMessage`, `QuickAction`, `RunTaskClient`. +- `CompanyStore` is `@MainActor`; all `@Published` mutations happen on the main actor. Concurrency is via structured `TaskGroup`; each concurrent branch MUST re-check `companyId == cid` after every `await` (account-switch safety — the existing idiom in `produceDraftInline`). +- Reuse these private `CompanyStore` members verbatim (already exist): `Self.execSteps(task:specialist:decisionCount:language:)`, `runRequest(for:language:)`, `buildDeliverable(from:task:)`, `taskRunner` (`(RunTaskRequest) async -> RunTaskResponse?`), `companyId` (`String?`), `company.decisions.count`, `Self.execStepNanos`, `Self.execDoneBeatNanos`, `flushActiveThread()`. +- Cap: `static let maxFanOut = 3`. +- `AgentRun` mutable fields are `var steps` and `var status` (mutate in place in the `activeAgentRuns` array). `AgentRun.id` is set to the task's id for lookup. +- Localization: every user-facing string has `.vi` and `.en` forms (`lang == .vi ? … : …`), matching the file's existing pattern. +- Preview/dev-only code stays under `#if DEBUG` (not relevant to these tasks, but don't remove existing guards). + +--- + +### Task 1: `RoadmapEngine.nextMoves` planner + tests + +**Files:** +- Modify: `codepet/Models/RoadmapEngine.swift` (add one static function) +- Test: `codepetTests/RoadmapEngineNextMovesTests.swift` + +**Interfaces:** +- Consumes: `RoadmapTask`, `RoadmapEngine.status(for:in:)`, `RoadmapPhase.order`, `DepartmentCompanions.companionId(for:)`. +- Produces: `static func RoadmapEngine.nextMoves(_ tasks: [RoadmapTask], limit: Int) -> [RoadmapTask]`. + +- [ ] **Step 1: Write the failing tests** + +Create `codepetTests/RoadmapEngineNextMovesTests.swift`: + +```swift +import XCTest +@testable import codepet + +final class RoadmapEngineNextMovesTests: XCTestCase { + // Helper: a codepetCanDo-eligible task by default (who: .does, not done/drafted, no deps). + private func task(_ id: String, dept: String?, phase: RoadmapPhase, + who: TaskWho = .does, done: Bool = false, drafted: Bool = false, + dependsOn: [String] = []) -> RoadmapTask { + RoadmapTask(id: id, title: id, detail: "", phase: phase, who: who, + dependsOn: dependsOn, done: done, drafted: drafted, dept: dept) + } + + func testPicksFirstCodepetCanDoPerDistinctDeptInRoadmapOrder() { + let tasks = [ + task("m1", dept: "mkt", phase: .launch), + task("e1", dept: "eng", phase: .build), + task("e2", dept: "eng", phase: .build), // same dept as e1 → skipped + task("d1", dept: "design", phase: .foundation), + ] + let picked = RoadmapEngine.nextMoves(tasks, limit: 3).map(\.id) + // Ordered by phase: foundation(d1) < build(e1) < launch(m1); one per dept. + XCTAssertEqual(picked, ["d1", "e1", "m1"]) + } + + func testCapLimitsCount() { + let tasks = [ + task("d1", dept: "design", phase: .foundation), + task("e1", dept: "eng", phase: .build), + task("m1", dept: "mkt", phase: .launch), + task("f1", dept: "fin", phase: .grow), + ] + XCTAssertEqual(RoadmapEngine.nextMoves(tasks, limit: 2).map(\.id), ["d1", "e1"]) + } + + func testSkipsIneligibleTasks() { + let tasks = [ + task("done1", dept: "eng", phase: .build, done: true), // done + task("you1", dept: "design", phase: .build, who: .you), // needsYou + task("draft1", dept: "mkt", phase: .build, drafted: true), // needsApproval + task("blocked1", dept: "fin", phase: .build, dependsOn: ["x"]), // blocked (dep x not done) + task("x", dept: "fin", phase: .build, done: false), // the missing dep target (not done) + task("nodept", dept: nil, phase: .build), // no dept + task("nomap", dept: "zzz", phase: .build), // dept has no companion mapping + task("ok", dept: "ops", phase: .build), // the only eligible one + ] + XCTAssertEqual(RoadmapEngine.nextMoves(tasks, limit: 3).map(\.id), ["ok"]) + } + + func testEmptyWhenNothingActionableOrLimitZero() { + let none = [task("you1", dept: "eng", phase: .build, who: .you)] + XCTAssertEqual(RoadmapEngine.nextMoves(none, limit: 3).count, 0) + XCTAssertEqual(RoadmapEngine.nextMoves([], limit: 3).count, 0) + XCTAssertEqual(RoadmapEngine.nextMoves([task("e1", dept: "eng", phase: .build)], limit: 0).count, 0) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +xcodebuild test -project CodePet.xcodeproj -scheme codepet \ + -only-testing:codepetTests/RoadmapEngineNextMovesTests CODE_SIGNING_ALLOWED=NO 2>&1 | tail -20 +``` +Expected: FAIL — "type 'RoadmapEngine' has no member 'nextMoves'". + +- [ ] **Step 3: Implement `nextMoves`** + +In `codepet/Models/RoadmapEngine.swift`, add inside `enum RoadmapEngine` (e.g. after `nextStep`): + +```swift + /// Up to `limit` parallelizable "next moves" for the chat fan-out: the first + /// `codepetCanDo` task in each DISTINCT department that maps to a specialist + /// companion, in roadmap order (phase order, then array position). Pure. + static func nextMoves(_ tasks: [RoadmapTask], limit: Int) -> [RoadmapTask] { + guard limit > 0 else { return [] } + let ordered = tasks.enumerated().sorted { a, b in + a.element.phase.order != b.element.phase.order + ? a.element.phase.order < b.element.phase.order + : a.offset < b.offset + }.map { $0.element } + var seenDepts = Set() + var out: [RoadmapTask] = [] + for task in ordered { + guard let dept = task.dept, + DepartmentCompanions.companionId(for: dept) != nil, + !seenDepts.contains(dept), + status(for: task, in: tasks) == .codepetCanDo else { continue } + seenDepts.insert(dept) + out.append(task) + if out.count == limit { break } + } + return out + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +xcodebuild test -project CodePet.xcodeproj -scheme codepet \ + -only-testing:codepetTests/RoadmapEngineNextMovesTests CODE_SIGNING_ALLOWED=NO 2>&1 | tail -20 +``` +Expected: PASS — `Executed 4 tests, with 0 failures`, `** TEST SUCCEEDED **`. + +- [ ] **Step 5: Commit** + +```bash +git add codepet/Models/RoadmapEngine.swift codepetTests/RoadmapEngineNextMovesTests.swift +git commit -F - <<'EOF' +feat(chat): RoadmapEngine.nextMoves planner for parallel fan-out + +Pure: first codepetCanDo task per distinct department (with a specialist +companion), roadmap order, capped. Backs the chat fan-out. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +--- + +### Task 2: `CompanyStore` fan-out engine + +**Files:** +- Modify: `codepet/Managers/CompanyStore.swift` + +**Interfaces:** +- Consumes: `RoadmapEngine.nextMoves` (Task 1); existing private members listed in Global Constraints; `AgentRun`, `AgentRunStatus`, `DepartmentCatalog`, `DepartmentCompanions`. +- Produces: `@Published var activeAgentRuns: [AgentRun]`, `@Published private(set) var isFanningOut: Bool`, `static let maxFanOut`, `func fanOutNextMoves(language:)`. + +- [ ] **Step 1: Add published state + the cap** + +In `codepet/Managers/CompanyStore.swift`, immediately after the `runningTaskIds` declaration (`@Published var runningTaskIds: Set = ...`, ~line 43), add: + +```swift + /// Live parallel department-agent runs (the chat fan-out). Rendered as one + /// AgentsWorkingRow; empty ⇒ no row. Seeded by `fanOutNextMoves`, cleared when + /// the whole fan-out completes; each agent's draft lands in `chatMessages`. + @Published var activeAgentRuns: [AgentRun] = [] + /// True while a fan-out is in flight — serializes it against a normal chat turn + /// and disables the composer (same busy model as a single run). + @Published private(set) var isFanningOut: Bool = false + /// Max concurrent department agents per fan-out (bounds credit spend + latency). + static let maxFanOut = 3 +``` + +- [ ] **Step 2: Add `fanOutNextMoves` + `runFanOutAgent`** + +In `codepet/Managers/CompanyStore.swift`, add these two methods right after `produceDraftInline(...)` (after its closing brace, ~line 777): + +```swift + /// Fan out the next actionable task in up to `maxFanOut` departments as parallel + /// department-agent runs, shown live via `activeAgentRuns` (AgentsWorkingRow). + /// Each agent's draft lands in the transcript as it finishes. Account-guarded. + func fanOutNextMoves(language: AppLanguage) async { + guard !isFanningOut, !isCompanionTyping, !isStreaming else { return } + let plan = RoadmapEngine.nextMoves(company.tasks, limit: Self.maxFanOut) + guard !plan.isEmpty else { + chatMessages.append(CopilotMessage(role: .companion, text: language == .vi + ? "Bạn đang không có việc nào mình chạy được ngay — lộ trình đã gọn rồi." + : "You're all caught up — no open tasks I can run right now.")) + flushActiveThread() + return + } + let cid = companyId + isFanningOut = true + + let now = Date() + var seeded: [(run: AgentRun, task: RoadmapTask)] = [] + for task in plan { + let deptName = DepartmentCatalog.find(task.dept)?.name ?? (task.dept ?? "") + let companionId = task.dept.flatMap { DepartmentCompanions.companionId(for: $0) } + ?? company.companionId + let specialist: (companionId: String, deptName: String)? = + deptName.isEmpty ? nil : (companionId, deptName) + let steps = Self.execSteps(task: task, specialist: specialist, + decisionCount: company.decisions.count, language: language) + let run = AgentRun(id: task.id, companionId: companionId, deptName: deptName, + taskTitle: task.title, steps: steps, status: .working, startedAt: now) + seeded.append((run, task)) + } + activeAgentRuns = seeded.map { $0.run } + + await withTaskGroup(of: Void.self) { group in + for item in seeded { + group.addTask { + await self.runFanOutAgent(runId: item.run.id, task: item.task, + cid: cid, language: language) + } + } + } + + guard companyId == cid else { activeAgentRuns = []; isFanningOut = false; return } + try? await Task.sleep(nanoseconds: Self.execDoneBeatNanos) // let final pills show + guard companyId == cid else { activeAgentRuns = []; isFanningOut = false; return } + activeAgentRuns = [] + isFanningOut = false + flushActiveThread() + } + + /// One agent's run inside a fan-out: reveal its steps client-side while its + /// `taskRunner` call runs, then flip its `AgentRun` to done/failed and append + /// its draft (or an honest failure bubble). Mutations are main-actor; the + /// `taskRunner` await is where parallelism happens. Account-guarded via `cid`. + private func runFanOutAgent(runId: String, task: RoadmapTask, + cid: String?, language: AppLanguage) async { + let reveal = Task { [cid] in + let stepCount = activeAgentRuns.first(where: { $0.id == runId })?.steps.count ?? 0 + for idx in 0..&1 | tail -8 +``` +Expected: `** BUILD SUCCEEDED **`. +(If strict-concurrency emits `Sendable` WARNINGS on the `TaskGroup` capture, that is acceptable under Swift 5 mode — the build must still SUCCEED. If it ERRORS, report DONE_WITH_CONCERNS with the exact message.) + +- [ ] **Step 4: Commit** + +```bash +git add codepet/Managers/CompanyStore.swift +git commit -F - <<'EOF' +feat(chat): CompanyStore fan-out engine (parallel dept agents) + +fanOutNextMoves plans next moves, seeds activeAgentRuns, and fans out +concurrent RunTaskClient calls via a TaskGroup; each agent reveals its +steps, flips to done/failed, and appends its draft as it finishes. +Account-guarded per branch; composer serialized via isFanningOut. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +--- + +### Task 3: `CopilotChatView` wiring (trigger chip + live row + busy state) + +**Files:** +- Modify: `codepet/Views/Copilot/CopilotChatView.swift` + +**Interfaces:** +- Consumes: `CompanyStore.fanOutNextMoves(language:)`, `activeAgentRuns`, `isFanningOut` (Task 2); `AgentsWorkingRow(runs:)`; `QuickAction`. + +- [ ] **Step 1: Fold `isFanningOut` into the busy state** + +In `codepet/Views/Copilot/CopilotChatView.swift`, change `isChatBusy` (~line 40): + +```swift + private var isChatBusy: Bool { companyStore.isCompanionTyping || companyStore.isStreaming || companyStore.isFanningOut } +``` + +And in `canSend` (~line 140-142), add the fan-out flag — change: +```swift + && !companyStore.isCompanionTyping && !companyStore.isStreaming +``` +to: +```swift + && !companyStore.isCompanionTyping && !companyStore.isStreaming && !companyStore.isFanningOut +``` + +- [ ] **Step 2: Add the "Run my next moves" quick-action chip** + +In `quickActions` (~line 246-267), replace the `return` statement at the end so the fan-out chip is prepended. Change: + +```swift + return (0.. 0 { withAnimation { proxy.scrollTo("agents", anchor: .bottom) } } + } +``` + +- [ ] **Step 5: Build to verify** + +Run: +```bash +xcodebuild build -project CodePet.xcodeproj -scheme codepet \ + -configuration Debug CODE_SIGNING_ALLOWED=NO 2>&1 | tail -8 +``` +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 6: Commit** + +```bash +git add codepet/Views/Copilot/CopilotChatView.swift +git commit -F - <<'EOF' +feat(chat): wire the parallel fan-out into the chat UI + +Add the "Run my next moves" quick-action chip (routes to fanOutNextMoves), +render the live AgentsWorkingRow in the message list with auto-scroll, and +disable the composer while a fan-out is in flight. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +--- + +## Self-Review + +**Spec coverage:** +- Planner (client-side, per-dept, cap 3, roadmap order) → Task 1. ✅ +- `activeAgentRuns` + `isFanningOut` + `maxFanOut` + `fanOutNextMoves` + concurrent `TaskGroup` + per-branch `cid` re-check + drafts-as-they-finish + clear-at-end → Task 2. ✅ +- Trigger chip, live `AgentsWorkingRow`, composer disabled → Task 3. ✅ +- Failure honesty (`nil`/429 → `.failed` + honest bubble) → Task 2 `runFanOutAgent` else-branch. ✅ +- Empty-plan honest bubble → Task 2 `fanOutNextMoves` guard. ✅ +- Out-of-scope (LLM/CF planner, streamed steps, chat-while-running, 429 decode) → not built. ✅ + +**Placeholder scan:** No TBD/TODO; every step has complete code + exact commands with expected output. ✅ + +**Type consistency:** `RoadmapEngine.nextMoves(_:limit:)` defined in Task 1, consumed in Task 2. `activeAgentRuns`/`isFanningOut`/`fanOutNextMoves` defined in Task 2, consumed in Task 3. `AgentRun(id:companionId:deptName:taskTitle:steps:status:startedAt:)`, mutable `steps`/`status`, and `AgentsWorkingRow(runs:)` match the shipped component. `runRequest`/`buildDeliverable`/`taskRunner`/`execSteps`/`companyId`/`flushActiveThread` match verified `CompanyStore` signatures. ✅ + +**Note on concurrency:** Swift 5 mode + `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`: the `TaskGroup` children call the `@MainActor` `runFanOutAgent`, whose `await taskRunner(...)` suspends the actor so N HTTP requests are in flight concurrently (parallel I/O), while all `@Published` mutations remain main-actor-serial (no data race). Any `Sendable` diagnostics are warnings, not errors, in this mode. diff --git a/docs/superpowers/plans/2026-07-28-reconcile-redesign-onto-main.md b/docs/superpowers/plans/2026-07-28-reconcile-redesign-onto-main.md new file mode 100644 index 0000000..90e4ab4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-reconcile-redesign-onto-main.md @@ -0,0 +1,61 @@ +# Reconcile `feat/chat-redesign` onto post-#37 `main` — execution guide + +**Branch:** `reconcile/redesign-onto-main` (a copy of `feat/chat-redesign` @ `e68ffcd`). **Merging in:** `origin/main` @ `4141d1d`. Merge-base `e9de8e0`. +**Direction (founder-approved):** the SidebarView redesign + this session's chat features WIN, re-seated on main's post-#37 foundation; ALIGN to main (adopt `Views/Roadmap` + `Views/SecondBrain`; keep Overview & Summary DELETED); rewire the composer draft to `companyStore.chatDraft`. + +## Do it in this order + +1. `git merge origin/main --no-edit` (expect conflicts in 6 files + Overview/Roadmap location conflicts). Do NOT abort. + +2. **Resolve the 6 content conflicts** per the table below. + +3. **Adopt main's directory layout:** ensure `Views/Roadmap/RoadmapView.swift`, `Views/Roadmap/RoadmapMapView.swift`, `Views/SecondBrain/SecondBrainView.swift`, `Views/SecondBrain/SecondBrainPanel.swift` are main's versions; **delete the entire `codepet/Views/Overview/` directory** (all 8 files: `RoadmapView`, `RoadmapMapView`, `SecondBrainView`, `SecondBrainPanel`, `OverviewBoardView`, `PhaseColumnView`, `RoadmapHeaderView`, `TaskCardView`). (Synchronized folder groups → no `project.pbxproj` edit needed; deleting from disk syncs.) + +4. **Delete `SummaryView`** to align with main's Summary removal: remove `codepet/Views/**/SummaryView.swift` if present on feat, and every `.summary` reference (AppView case, AppShellView branch). Grep `SummaryView` and `\.summary` to confirm none remain. + +5. Build (foreground) → fix any dangling refs → full suite → commit the merge (NO push). + +## Per-file resolutions + +### `codepet/Models/AppView.swift` +Take **main's** enum (cases: `chat, roadmap, secondBrain, tasks, library, environment, company, settings, billing, support` — NO `.summary`, NO `.overview`; keep main's icons/titles). Set `navTabs = [.roadmap, .secondBrain, .company, .tasks, .library, .environment]` (main's list **minus `.chat`** — chat is the wordmark home, not a tab). `from(navDestination:)` is identical on both — keep it. + +### `codepet/Views/Shell/AppShellView.swift` +Take **feat's** version (the `SidebarView(collapsed:)` shell + collapse toggle + full-width content). Make ONE edit: **remove the `else if companyStore.view == .summary { SummaryView() }` branch** from `content`. Everything else in feat's file stays (SidebarView already provides Wake→`.environment` + Upgrade→`.billing`). Confirm it compiles without `appState`/`accent` (SidebarView owns accent). + +### `codepet/Views/Copilot/CopilotChatView.swift` +Take **feat's** redesigned file wholesale (orb, ChatComposer, cards, ChatLandingState, feedback, dept-focus — all of it). THEN apply the **draft → `companyStore.chatDraft` rewire** (main's intent: a roadmap-card tap navigates to chat with the draft intact): +- Remove `@State private var draft = ""`. +- Everywhere the view read/wrote `draft`, use `companyStore.chatDraft` instead: + - the composer binding: pass `draft: $companyStore.chatDraft` into `ChatComposer(...)`. + - `onStarter = { companyStore.chatDraft = $0; inputFocused = true }`. + - `send()`: read `companyStore.chatDraft`, and clear via `companyStore.chatDraft = ""` (keep the existing `mode.shape(...)` + `sendChat(..., department: selectedDept)` + refocus logic). + - `canSend` reads `companyStore.chatDraft`. +- `companyStore.chatDraft` exists post-merge (from main's CompanyStore, which auto-merges) and is `@Published var chatDraft: String`. Verify that exact name in the merged `CompanyStore.swift`; if main named it differently, use the real name. + +### `codepet/Views/Roadmap/RoadmapView.swift` +Take **main's** version (title "Roadmap"; task tap → `RoadmapDispatch.action(for:)` + `if RoadmapDispatch.navigatesToChat(action) { companyStore.select(.chat) }`). Discard feat's `Views/Overview/RoadmapView.swift`. (Optional: port feat's `#Preview` onto main's file — skip if it adds risk.) + +### `codepet/Views/SecondBrain/SecondBrainView.swift` +Take **main's** thin wrapper (header + `SecondBrainPanel`). Discard feat's `Views/Overview/SecondBrainView.swift` (its duplicated roadmap chrome). + +### `codepetTests/AppViewTests.swift` +Rewrite to match the resolved `AppView`: `allCases` == main's 10 cases (no `.summary`); `navTabs == [.roadmap, .secondBrain, .company, .tasks, .library, .environment]`; keep the assertion `XCTAssertFalse(AppView.navTabs.contains(.chat))` (chat is home, not a tab); keep `from(navDestination: "roadmap") == .roadmap`. Do not assert `.summary`/`.overview`. + +## Auto-merge (no action — verify only) +- `codepet/Managers/CompanyStore.swift` — union of main's `chatDraft` (+ resets) and feat's `reactToMessage`/`sendChat(department:)`/`sendMessage(department:)`/Firebase imports. Both default `view = .chat`. +- `codepet/Views/CodepetTheme.swift` — feat's `chatCanvas`/`chatOrbCore` + main's `navTab()` removal (unused). +- All ~19 new redesign files + new test files — additive, no conflict. + +## Verify + commit +- `xcodebuild build -project codepet.xcodeproj -scheme codepet -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -3` → `** BUILD SUCCEEDED **`. Fix any dangling reference to `.summary`/`SummaryView`/`OverviewView`/deleted Overview files. +- `xcodebuild test … CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "codepetTests.xctest' (passed|failed)|Executed [0-9]+ tests" | tail -3` → `codepetTests.xctest` 0 real failures (known Firebase flake wobbles the count). +- Commit the merge (keep the merge commit; do NOT squash; do NOT push): +``` +git add -A +git commit --no-edit # or a clear message if git didn't stage a merge message +``` +Message should read: `merge: reconcile chat redesign onto post-#37 main (SidebarView wins; adopt Views/Roadmap; drop Summary; draft→chatDraft)`. + +## Constraints +- FOREGROUND xcodebuild only. Do NOT push. Do NOT touch `project.pbxproj`. SourceKit "Cannot find … in scope" = false positives; xcodebuild is authoritative. diff --git a/docs/superpowers/specs/2026-07-27-chat-as-main-interface-design.md b/docs/superpowers/specs/2026-07-27-chat-as-main-interface-design.md new file mode 100644 index 0000000..e3ec88b --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-chat-as-main-interface-design.md @@ -0,0 +1,43 @@ +# Chat as the main interface — design spec + +**Date:** 2026-07-27 +**Target:** `My-Outcasts/codepet` native macOS app, branch `feat/chat-redesign` (extends the chat redesign already on that branch). +**Status:** Approved design. + +## Goal +Make **chat the default, full-width main interface** of Codepet. Remove the Overview tab and the persistent 50% chat side-panel; split Overview's `Roadmap | Second Brain` toggle into two standalone pages. Preserve first-run onboarding. Do not restyle the top bar (its tab *set* changes, but its look/structure stays). + +## Decisions (locked with product owner) +1. Chat = full-width home + default landing. The always-on 50% right panel and the floating "C" toggle are **removed**; `content` is full-width. +2. **No Chat tab.** Chat is the default; the **Codepet wordmark becomes a Home button → chat**. Top-bar tabs become: **Summary · Roadmap · Second Brain · Company · Tasks · Library · Environment** (Overview removed; Second Brain right after Roadmap). +3. Roadmap and Second Brain each become their own page (the old Overview toggle is split). + +## Non-goals +- No left chat sidebar (top bar stays the only chrome). +- No new chat *visual* work — reuse the v2 chat already on this branch (orb hero, glossy composer, mode chip, icon pills, orb avatars, Thinking…, Copy/Regenerate). The only chat change is full-width layout. +- No backend/`CompanyStore` logic changes beyond the default-view value. +- First-run `OnboardingView` (gated in `ContentView` before the shell) is untouched; the in-chat enrichment interview now renders in the full-width chat. + +## Current structure (as-is) +- `AppShellView` = `VStack { topBar; Divider; HStack { content (per-tab, flex) | Divider | copilot (CopilotChatView, 50%) } }`, with a floating "C" `chatToggle` that collapses the copilot panel. +- `AppView.navTabs = [.summary, .overview, .company, .tasks, .library, .environment]`; `CompanyStore.view` defaults to `.overview`. +- `OverviewView` (256 lines) = a "How to read this map" pill + a `Roadmap | Second Brain` toggle + a KEY legend + a Project-Progress card + body that shows `RoadmapMapView(tasks:)` or `SecondBrainPanel(data:lang:)`. + +## Target structure +- `AppShellView` = `VStack { topBar; Divider; content (full-width) }`. No copilot panel, no `copilotCollapsed`, no `chatToggle`. +- `content` branches add `.chat → CopilotChatView()`, `.roadmap → RoadmapView()`, `.secondBrain → SecondBrainView()`; `.overview` branch removed. +- Codepet wordmark in `topBar` becomes a `Button { companyStore.selectedDeptKey = nil; companyStore.select(.chat) }`. +- `AppView`: add `.chat` and `.secondBrain`; retire `.overview`; `navTabs = [.summary, .roadmap, .secondBrain, .company, .tasks, .library, .environment]`; `from(navDestination: "roadmap") → .roadmap`. Titles: Chat = "Trò chuyện"/"Chat", Second Brain = "Bộ não thứ hai"/"Second Brain". Icons: chat `"message"`, secondBrain `"brain"`, roadmap keeps `"map"`. +- `CompanyStore.view` defaults to `.chat`; `reset()` sets `.chat`. +- `RoadmapView` = the map (`RoadmapMapView`) + the How-to-read pill + KEY legend + Project-Progress card, extracted from `OverviewView`, **minus the toggle**. `SecondBrainView` = `SecondBrainPanel` + the same header chrome, minus the toggle. `OverviewView` is deleted once both exist and nothing references it. +- `CopilotChatView` full-width: center the conversation list, the composer, and the empty-state hero in a **max-width ~720pt column** (readable, matches the references) instead of stretching edge-to-edge. + +## Testing +- Unit (extend `AppViewTests`): `navTabs` excludes `.overview`, includes `.roadmap` and `.secondBrain`, excludes `.chat`; `from(navDestination:"roadmap") == .roadmap`. +- Build green; full suite green (note: the Firebase-flake fix lives on a separate branch/PR #40 — this branch may still show the known crash-retry until #40 merges in). +- Views verified via SwiftUI previews + a human GUI pass (full-width chat in light/dark, logo→home, Roadmap & Second Brain pages, onboarding still lands in chat). + +## Risks +- Retiring `.overview` touches several files; the build breaks until AppView + AppShellView + OverviewView deletion land together (sequenced so each task boundary is green). +- Full-width chat must keep the existing message/scroll behavior — center via a max-width frame, don't rewrite the list. +- Removing the omnipresent chat panel is a real UX change (chat is now a destination, not always visible) — intended. diff --git a/docs/superpowers/specs/2026-07-27-native-chat-redesign-design.md b/docs/superpowers/specs/2026-07-27-native-chat-redesign-design.md new file mode 100644 index 0000000..cec42aa --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-native-chat-redesign-design.md @@ -0,0 +1,150 @@ +# Native chat redesign — design spec + +**Date:** 2026-07-27 +**Target:** `My-Outcasts/codepet` (native macOS SwiftUI app) — `codepet/Views/Copilot/CopilotChatView.swift` +**Status:** Approved design; ready for implementation plan. + +## Goal + +Rework the chat surface (the product's "spine") from today's sparse, left-aligned +empty state + thin bottom input into a modern AI-chat layout: a **centered, +personalized hero greeting**, an **elevated composer** with functional controls, +**quick-action pills** mapped to Codepet's real capabilities, and a **restyled +active conversation** — all within the existing chat column (no new sidebar). + +Reference direction: a *quiet hero* (minimalist, space-forward, per the Codepet +design north-star) plus an **Ask / Plan / Build** mode selector. + +## Non-goals + +- No new left chat sidebar (the app already has a department/nav rail + in-chat + History panel; a second nav surface is out of scope). +- No backend / `CompanyStore` / Cloud Function changes. This is a **view refactor + + restyle**. Chat send, streaming, thread switching, draft approve/redo, + interview cards, nav/setup chips all keep their current logic. +- No new fake affordances. Every control maps to real behavior (see Honesty). + +## Honesty constraints (why the design is shaped this way) + +- There is **no backend concept of chat modes** and **no build session** — the + current "Let's build" bar is a dead stub (`Button { }`). So modes are + implemented as **client-side intent-shaping** of the outgoing message, not + distinct backends. +- There is **no file-attachment feature**, so the composer has **no fake attach + button**. The `+` control opens a real **quick-actions menu** instead. +- The "Let's build" stub bar is **removed**, not restyled — its intent moves into + the Build mode. + +## Component structure + +Split `CopilotChatView` (612 lines) into focused subviews in the same file group: + +| Unit | Responsibility | Depends on | +|---|---|---| +| `CopilotChatView` | Thin shell: `header → (empty ? ChatEmptyState : messageList) → ChatComposer` | `CompanyStore` | +| `ChatEmptyState` | Centered hero greeting + quick-action pills | `CompanyStore` (names), sends via composer callback | +| `ChatComposer` | The composer, reused in empty + active states: multiline field, `+` menu, mode control, send | binding to draft + mode; `send` / `runQuickAction` callbacks | +| `ChatMode` | Pure enum: `.ask / .plan / .build`; `shape(_ text:) -> String` | none (unit-testable) | +| `CopilotBubble` | Unchanged logic; spacing/radius restyle only | `CompanyStore` | +| `ThreadListView` | Unchanged | `CompanyStore` | + +Each unit is independently understandable: `ChatComposer` knows nothing about the +message list; `ChatMode` is pure data; `ChatEmptyState` only renders + forwards taps. + +## 1. Empty-state hero (`ChatEmptyState`) + +- Centered `VStack`, vertically centered in the column. +- **Greeting**: Google Sans Flex (`CodepetTheme.inter`) ~34–40pt, semibold, + `-0.02em` tracking, `primaryText`, with the **company name** in `accentPurple`. + Personalized with `founderName` / `companyName` (same accessors as today). + Localized (en/vi), matching the existing greeting's bilingual pattern. + **Not** the Minecraft pixel font (matches web `.vhead h1`). +- **Glow**: one faint `accentPurple` radial behind the greeting. Must read well in + **both** light and dark (fainter in light). Suppressed under + `accessibilityReduceMotion` / reduce-transparency. +- **Quick-action pills** (row, wraps): capsule, `hairline` border, `bodyText`. + Replaces today's three left-aligned text chips. Each pill sends a canned + message through the same path as free text: + - Run a task + - Review the roadmap + - Set up a department + - Summarize where we are + + (Exact strings finalized in the plan; localized en/vi.) + +## 2. Composer (`ChatComposer`) + +- `surface` card, 16pt continuous radius, `floatingShadow`. +- Multiline `TextField(axis: .vertical)`, `lineLimit(1...6)`, plain style, + `inter(15)`, placeholder "Ask anything about your company…" (localized). +- Bottom control row: + - **`+`** — 30pt bordered square button → menu of the same quick actions as the + pills. This is how quick-actions stay reachable in an active conversation + (where the hero pills are gone). Real behavior; not a file picker. + - **Mode control** — `Ask ▾` pill → `Menu` of Ask / Plan / Build. Selected mode + persists for the session (client-only `@State`, no persistence needed for v1). + - **Send** — `accentPurple` filled circle, `arrow.up`, disabled unless + `canSend` (non-empty trimmed draft AND not typing/streaming — today's gate). +- Same component instance is used centered (empty) and docked at the column bottom + (active). A `Divider()` sits above it in active mode, as today. + +## 3. Modes (`ChatMode`) + +Pure, testable message-shaping. No backend change. + +- `.ask` *(default)* — returns text unchanged (today's behavior). +- `.plan` — wraps text with a planning intent so byte replies with concrete next + steps / a short plan. +- `.build` — biases text toward *running a task / producing a deliverable*, which + the chat already supports (`run_task` → draft cards). Copy kept **modest** so it + doesn't imply the (not-yet-native) build agent. + +`send()` becomes: `companyStore.sendChat(mode.shape(text), language: lang)`. +`shape` is pure → unit-tested for all three modes in both languages. + +## 4. Active conversation + +- `messageList` scroll behavior unchanged (auto-scroll on new message / typing). +- `CopilotBubble` logic untouched (text / draft / nav / setup / noted / interview / + producing). Restyle only: bump bubble radius to ~14pt, increase inter-message + spacing for a cleaner read, keep `me = accentPurple`, `companion = surface`. +- Typing/producing rows unchanged in logic. +- The separate "Let's build" bar is **deleted**. + +## 5. Header + +Content unchanged: "Your team" / "guiding · {company}" + History toggle +(the working thread switcher). Light spacing polish only. + +## 6. Tokens, theme, accessibility + +- All colors/spacing via `CodepetTheme` tokens → automatic light/dark via the + dynamic `Color.dyn` tokens. Verify the hero + glow in **both** appearances. +- Respect reduce-motion / reduce-transparency for the glow. +- Preserve keyboard behavior: `onSubmit` sends (Return), Shift+Return newline via + the multiline axis. Focus state preserved. + +## 7. Testing + +Follow existing `codepetTests` patterns: +- **Unit**: `ChatMode.shape` for `.ask/.plan/.build` × en/vi; `canSend` gating. +- **Behavior preserved**: sending (free text + pill + `+` menu), thread switch, + draft approve/redo, interview send/skip all still work (refactor must stay + green). No snapshot infra assumed; assert on view-model/pure logic. + +## Risks / watch-items + +- **Light mode**: mockups were dark-only; the hero glow and composer contrast must + be checked in light (cream) appearance before merge. +- **Refactor regressions**: `CopilotChatView` carries a lot of working behavior; + the split must be mechanical (move, don't rewrite) to avoid breaking drafts / + streaming / History. +- **Build-mode expectations**: keep copy modest; revisit when the native build + agent lands. +- **Verify on a signed/real build**, not just `next dev`-style previews — this is + the native app (per project conventions). + +## Rollout + +Branch off `My-Outcasts/codepet@main` → implement → verify in both appearances on a +signed build → PR to `main`. diff --git a/docs/superpowers/specs/2026-07-28-card-grammar-readability-design.md b/docs/superpowers/specs/2026-07-28-card-grammar-readability-design.md new file mode 100644 index 0000000..057d984 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-card-grammar-readability-design.md @@ -0,0 +1,118 @@ +# Message card grammar + chat readability — design spec + +**Date:** 2026-07-28 +**Target:** `My-Outcasts/codepet` (native macOS SwiftUI), branch `feat/chat-redesign` (PR #39) +**Adapts:** Phase 2 chat spec §10 (card grammar), reshaped for `feat/chat-redesign` (whose `CopilotBubble` already draws the seven payloads as six bespoke builders). Plus a founder-requested chat text-size/readability pass, bundled into this phase. +**Status:** Design agreed with the founder (Q&A 2026-07-28); ready for spec review → plan. + +## Goal + +1. **One card grammar (§10):** the seven `CopilotMessage` interactive payloads currently render six different ways, so nothing tells the founder which ones need them. Collapse them onto one tinted-card construction with six semantic hues — **gold, and only gold, means "you owe a decision."** +2. **Readability:** the chat message body + card text render in `.pixelSystem(11–12)` — small and pixel-font. Move chat reading text to `CodepetTheme.inter` at a standard size (matching the composer's `inter(15)` and Claude/ChatGPT), keeping the pixel font for brand bits only. + +## Decisions (locked with the founder) + +- **Chat body text → `CodepetTheme.inter`, ~15pt.** Keep the pixel font only for brand (the sidebar wordmark) — not for chat message/card reading text. +- Semantic hue system per §10 (gold = decision owed). +- Card construction is **smooth** (RoundedRectangle continuous), not the older pixel "stepped border" — consistent with the redesign's existing cards (ChatEmptyState cards radius 14, composer radius 16). This is a deliberate deviation from §10's "stepped border" wording to match the shipped redesign aesthetic. + +## Non-goals + +- No change to any payload's BEHAVIOR (Approve/Redo/Revise, nav activate, setup enable, interview send/skip, first-run action, noted display all keep their current logic) — this restyles the wrapper + text, not the actions. +- No Cloud Function / `CompanyStore` / model / schema change. No new deps. +- `producing` is NOT a card — it already routes to `ChatThinkingRow` (§8, shipped). Not touched. +- Not touching §9 message un-bubble, §7 composer, §3 aura, §5/6 landing — separate phases. + +## Design + +### 1. `MessageCardStyle` — `codepet/Models/MessageCardStyle.swift` (new, pure) + +SwiftUI-free (imports SwiftUI only for `Color`), testable: + +```swift +enum MessageCardKind { case draft, interview, setupSuggestion, firstRunAction, noted, navChip } + +enum MessageCardStyle { + /// Which kind a message is, from its payload. nil for plain text and for `producing`. + /// Precedence when a message somehow carries more than one payload: + /// draft > interview > setupSuggestion > firstRunAction > noted > navChip. + static func kind(for m: CopilotMessage) -> MessageCardKind? + /// The single hue that carries the card's meaning. + static func hue(for kind: MessageCardKind, companionAccent: Color) -> Color +} +``` + +Hue table: + +| kind | hue | meaning | +|---|---|---| +| `draft` | `CodepetTheme.accentGold` | a decision is owed | +| `firstRunAction` | `companionAccent` | the companion is offering | +| `interview` | `CodepetTheme.accentBlue` | you are being asked | +| `setupSuggestion` | `CodepetTheme.accentTeal` | capability change | +| `noted` | `CodepetTheme.mutedText` | receipt; recedes | +| `navChip` | `CodepetTheme.hairline` | pure navigation | + +`kind(for:)` returns nil when `m.producing` is true or when the message carries no payload (plain text). It checks payloads in the documented precedence order (a real message carries one; precedence only disambiguates the pathological case + pins test behavior). + +### 2. `MessageCard` — `codepet/Views/Copilot/MessageCard.swift` (new) + +One construction, hue-parameterized, wrapping arbitrary content: + +```swift +struct MessageCard: View { + let hue: Color + @ViewBuilder var content: Content + // hue.opacity(0.12) fill over CodepetTheme.surface, same-hue 1pt border, + // RoundedRectangle(cornerRadius: 12, .continuous), uniform padding 12, + // frame(maxWidth: .infinity, alignment: .leading) +} +``` + +Only the hue varies between the six kinds. Callers pass `MessageCardStyle.hue(for: kind, companionAccent:)`. + +### 3. Refactor `CopilotBubble` payload builders — `codepet/Views/Copilot/CopilotChatView.swift` + +Each of the six builders keeps its **inner content and actions unchanged**, but is wrapped in `MessageCard(hue:)` instead of its ad-hoc container (`CodepetCard`, bare `Capsule` chips, etc.). Resolve the companion accent once: `PetCharacter.all[companyStore.company.companionId]?.color ?? CodepetTheme.accentPurple`. + +- `draftCard`: wrap in `MessageCard`. Hue = **gold while unapproved** (`!message.draftApproved`); once approved, recede to `CodepetTheme.accentTeal` (the existing "Added to Library" done-state colour). This keeps "gold = decision still owed" honest. +- `interviewCard` → blue; `setupCard` → teal; `notedChip` → muted; `navChip` → hairline; first-run action → companion accent. +- `navChip` / first-run / `noted` currently render as bare capsules/chips; they become small `MessageCard`s too, so the whole surface shares one grammar. +- The `body`'s payload dispatch keeps working; optionally route through `MessageCardStyle.kind(for:)` — but at minimum each builder passes the correct hue. + +### 4. Readability pass — same file (and only reading text) + +Move chat **reading** text from `.pixelSystem` to `CodepetTheme.inter`, on this scale (pixel font stays ONLY on the sidebar wordmark, untouched here): + +| Element | before | after | +|---|---|---| +| message bubble body (me + companion) | `pixelSystem(12)` | `inter(15)` | +| card title (draft, interview ask) | `pixelSystem(12, semibold)` | `inter(15, weight: .semibold)` | +| setup name | `pixelSystem(12, semibold)` | `inter(14, weight: .semibold)` | +| secondary/detail (draft body, why lines, noted facts) | `pixelSystem(11)` | `inter(13)` | +| interview answer field | `pixelSystem(12)` | `inter(14)` | +| primary buttons (Approve/Redo, Send/Skip, verb, nav, first-run) | `pixelSystem(10–11, semibold)` | `inter(12–13, weight: .semibold)` | +| revise chips / smallest labels | `pixelSystem(9, semibold)` | `inter(11, weight: .semibold)` | + +No copy changes; en/vi unaffected. + +## Testing + +- **`MessageCardStyleTests` (new, pure):** every `MessageCardKind` → its documented hue (pass a sentinel `companionAccent` and assert `firstRunAction` returns exactly it); `kind(for:)` returns the right kind for a message carrying each single payload; `nil` for a plain-text message and for a `producing` message; a message carrying two payloads resolves by the documented precedence (`draft` beats `interview`, etc.). +- **Build gate:** foreground `xcodebuild build … CODE_SIGNING_ALLOWED=NO` → BUILD SUCCEEDED. Full suite stays at 0 real failures (known Firebase-init flake aside) + the new MessageCardStyle tests. +- **Visual, human-verified on a signed build (built + launched):** each payload reads as a tinted card with its hue (trigger a draft → gold; interview → blue; etc.); approved draft recedes to teal; text is clearly larger/cleaner (Inter 15) in both light + dark. + +## Files + +**New:** `codepet/Models/MessageCardStyle.swift`, `codepet/Views/Copilot/MessageCard.swift`, `codepetTests/MessageCardStyleTests.swift` +**Modified:** `codepet/Views/Copilot/CopilotChatView.swift` (`CopilotBubble` builders: wrap in `MessageCard` + font scale). + +## Risks / watch-items + +- **Behavior regressions:** `CopilotBubble` carries a lot of working action logic; the refactor must be mechanical (swap the container + fonts, keep the buttons/closures), verified by the suite staying green + a signed-build click-through of Approve/Redo/nav/setup/interview. +- **Hue legibility in light mode:** hue@12% over cream `surface` for the paler accents (gold, teal) — confirm the tint is visible but not garish in light; adjust opacity only if needed (cosmetic). +- **`CodepetCard` may become unused** once the builders stop calling it — if so, leave it (used elsewhere) or note it; don't delete shared infra without checking other callers. + +## Rollout + +Implement on `feat/chat-redesign` → build + suite green → build & launch signed for the founder's visual sign-off → push (rebasing over any concurrent branch commits). Nothing merges (branch held). Follow up on the W1 tracker's "Run-card design (Approve / Open / Redo)" task. diff --git a/docs/superpowers/specs/2026-07-28-chat-containment-cohesion-design.md b/docs/superpowers/specs/2026-07-28-chat-containment-cohesion-design.md new file mode 100644 index 0000000..1393b65 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-chat-containment-cohesion-design.md @@ -0,0 +1,163 @@ +# Chat containment + cohesion — design spec + +**Date:** 2026-07-28 +**Target:** `My-Outcasts/codepet` (native macOS SwiftUI app), branch `feat/chat-redesign` (PR #39) +**Files:** `codepet/Views/Copilot/CopilotChatView.swift`, `codepet/Views/Copilot/ChatEmptyState.swift`, + one new `codepet/Views/Copilot/ChatBackdrop.swift` +**Status:** Approved design; ready for implementation plan. + +## Goal + +The chat became the app's full-width home page (commit `ea9f701`). The empty +state is contained (a 720pt centered column with a soft purple glow), but the +**active-conversation composer is not** — it renders as `composerView.padding(10)` +with no width cap, so it sprawls edge-to-edge in the full-width page. The active +conversation also has no ambient glow, so moving from the empty hero into a live +thread feels like crossing between two unrelated surfaces. + +Reference direction (user-supplied: Axora/DeepAI, Cronius): everything lives in a +tight, clearly-contained centered column over a single ambient-washed background. +Per the Codepet north-star, this is a **containment + cohesion pass — not a +structural rewrite**. The composer internals, message bubbles, orb, greeting, +cards, and all chat logic are unchanged. + +## Non-goals + +- No change to the composer's internals (`+` quick-actions menu, `Ask ▾` mode + control, gradient send button) — the user chose "keep the current structure". +- No change to message bubbles, the companion orb, the greeting, the quick-action + cards, or any `CompanyStore` / send / streaming / thread logic. +- No new visible in-composer capability pills (explicitly deferred; not this pass). +- No column-width change: the column stays **720pt** everywhere (empty + active), + matching the existing empty-state and message-list caps. + +## Decisions (locked with the user) + +- **Column width:** ~720pt, unchanged. The active composer is brought into the + same 720 column so its edges align with the message list. +- **Active-chat glow:** yes — the active conversation gets the same soft ambient + purple glow the empty state has, so empty→active reads as one continuous surface. +- **Divider:** the hard full-width `Divider()` above the active composer is + **dropped**. It reads wrong beneath a 720-capped column and the references have + none; the composer's existing `floatingShadow` provides separation as it floats + over the ambient backdrop. + +## Component structure + +| Unit | Responsibility | Depends on | +|---|---|---| +| `ChatBackdrop` *(new)* | The ambient purple radial wash, gated on `accessibilityReduceTransparency`. Pure decoration, no state. Placed once behind the whole chat. | `CodepetTheme` | +| `CopilotChatView` | Owns the shared `ChatBackdrop` background behind both branches; contains the docked composer in a 720 column; no divider. | `CompanyStore` | +| `ChatEmptyState` | Drops its private `brandWash`; renders only foreground content (orb + greeting + composer + cards). The glow now comes from `CopilotChatView`'s `ChatBackdrop`. | `CompanyStore` | + +`ChatBackdrop` is independently understandable: it takes no inputs beyond the +environment's reduce-transparency flag and draws one gradient. It can be dropped +behind any view. Extracting it removes the duplicated radial-gradient code that +would otherwise exist in both the empty state and the active branch. + +## Changes in detail + +### 1. `ChatBackdrop` (new file) + +Move the exact gradient currently inlined as `ChatEmptyState.brandWash` into a +standalone `struct ChatBackdrop: View`: + +- `RadialGradient` of `CodepetTheme.accentPurple.opacity(0.16) → .clear`, + `center: .center`, `startRadius: 0`, `endRadius: 420`, `.blur(radius: 60)`, + `.allowsHitTesting(false)`, filling `maxWidth/maxHeight: .infinity`. +- Suppressed entirely under `@Environment(\.accessibilityReduceTransparency)` + (returns an empty view / clear), matching today's behavior. + +This is a straight extraction — the empty-state glow must look identical to today. + +### 2. `ChatEmptyState` + +- Remove the private `brandWash` computed view and the `ZStack { brandWash; … }` + wrapper. The body becomes just the foreground `VStack` (orb, greeting, composer, + cards) filling `maxWidth/maxHeight: .infinity`. +- Remove the now-unused `@Environment(\.accessibilityReduceTransparency)` if it is + no longer referenced elsewhere in the file (it moves into `ChatBackdrop`). +- No layout/spacing change to the foreground content — the composer stays capped + at 720 and the cards at 720. + +### 3. `CopilotChatView` + +- Wrap the whole `body` content in `ZStack { ChatBackdrop(); VStack(spacing: 0) { … } }` + so the ambient wash sits behind **both** the empty and active branches (fixed, + not scrolling with the message list). +- **Empty branch:** unchanged (`ChatEmptyState(…) { composerView }`) — it now + reads its glow from the shared backdrop. +- **Active branch:** replace + + ``` + messageList + Divider() + composerView.padding(10) + ``` + + with + + ``` + messageList + HStack { Spacer(minLength: 0) + composerView.frame(maxWidth: 720) + Spacer(minLength: 0) } + .padding(.vertical, 10) + ``` + + i.e. drop the `Divider()`, and center the composer using the **exact same** + `Spacer / maxWidth: 720 / Spacer` pattern the `messageList` already uses (no + extra horizontal padding), so the composer's column edges track the message + bubbles' column edges at every window width. The composer keeps its own + `floatingShadow`, which now provides the message/composer separation the divider + used to. + +## Data flow / behavior + +No behavioral change. Draft/mode/focus still live in `CopilotChatView`; the same +`composerView` instance is used in both branches (so the first-send refocus fix in +`send()` still applies). Send, streaming, thread switching, draft approve/redo, +interview, nav/setup chips all keep their current logic. The backdrop is inert +decoration and `.allowsHitTesting(false)`, so it never intercepts taps. + +## Error handling + +None applicable — this is layout + decoration. The reduce-transparency path is the +only branch, and it degrades to no glow (already the established pattern). + +## Testing + +- **Build gate:** `xcodebuild build -project codepet.xcodeproj -scheme codepet + -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO` → BUILD SUCCEEDED. +- **Suite:** full unit suite stays at **303 tests / 0 real failures** (the overall + `** TEST FAILED **` is the known `CompanyStoreScaffordOnboardingTests` + Firebase-init flake, fixed on PR #40 — not a regression). +- **No new pure logic** to unit-test — the change is layout/decoration. There is no + snapshot infra in the project; the payoff is verified by the GUI pass below. +- **GUI pass (light + dark, real window):** + - Active conversation: composer sits in the centered 720 column, edges aligned + with message bubbles; no edge-to-edge sprawl; no divider; `floatingShadow` + reads as separation. + - The ambient purple glow is present and consistent across empty and active + (fixed, not scrolling); fainter/acceptable in light (cream) appearance. + - Reduce Transparency ON → glow suppressed in both states. + - Empty state looks identical to before the `brandWash`→`ChatBackdrop` extraction. + +## Risks / watch-items + +- **Light mode:** the glow opacity (0.16) was tuned in dark; confirm it isn't muddy + over the cream `pageBackground` in the active conversation (larger visible area + than the empty hero). Adjust opacity only if it reads poorly — cosmetic. +- **Backdrop scroll independence:** the `ChatBackdrop` must live outside the + `ScrollView` (as a `ZStack` background of the view), so it stays fixed while + messages scroll. Putting it inside `messageList` would be wrong. +- **Column-edge alignment:** the composer uses the identical `Spacer / maxWidth: + 720 / Spacer` centering as `messageList`, so the 720 columns coincide. Their + internal paddings differ slightly (`messageList` VStack pads 12, composer pads + 14) → a ~2pt inset difference; acceptable, but verify bubbles and composer read + as aligned in the GUI pass. + +## Rollout + +Implement on `feat/chat-redesign` (PR #39) → build + suite green → push. Nothing is +merged (the branch is held for the user's GUI sign-off, consistent with the rest of +the redesign). diff --git a/docs/superpowers/specs/2026-07-28-chat-scenario-mockups-agents-working-design.md b/docs/superpowers/specs/2026-07-28-chat-scenario-mockups-agents-working-design.md new file mode 100644 index 0000000..dbac7fd --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-chat-scenario-mockups-agents-working-design.md @@ -0,0 +1,188 @@ +# Chat scenario mockups + parallel "agents working" UI — design + +**Date:** 2026-07-28 +**Owner:** Mona +**Branch:** `feat/chat-redesign` (view-layer only; no engine changes) +**Status:** Approved design → spec + +## Goal + +Two things: + +1. **Scenario mockups** — real SwiftUI preview screens that show the chat in each + of its main working modes, so we can review the chat UX as complete states + (not one bubble at a time). Four scenarios: working with the **user**, with the + **roadmap**, with **tasks**, and **setting up the environment**. +2. **A more specific "which agents are currently working" UI** — today the chat + shows a single agent working (`ExecLogRow`). We want the inline, in-chat view + for **multiple department agents working in parallel**, modeled on OpenAI + Codex's run list (per-run status pill, elapsed time, step counter, live step + checklist). This is the one genuinely new component. + +## Non-goals + +- **No engine parallelism.** Actually running multiple department agents at once + is a separate follow-on. This spec delivers the **view + data model**, driven by + mock/fixture data. The mock proves the UI so the run engine can be wired to it + later. +- **No new persistence.** `CopilotMessage` is session-only today; mocks use + in-memory fixtures. +- **No production entry point yet** beyond `#Preview`. The mocks are dev-only + (Xcode previews). Wiring `AgentsWorkingRow` into the live chat stream is called + out as follow-on, not built here. +- No changes to `RoadmapView`, `TasksView`, `EnvironmentView` themselves — the + scenarios are about the *chat's* view of those surfaces. + +## Design principle: mocks drive real components + +`CopilotBubble` already renders every chat state we need — `textBubble`, +`navChip`, `setupCard`, `notedChip`, `draftCard`, `firstRunAction` — and +`ExecLogRow` renders the single-agent run. The mock views therefore **feed +fixture `[CopilotMessage]` into the real components**, so the mockups look exactly +like production and cannot drift from it. The only bespoke UI is the new +`AgentsWorkingRow`, which is written as a **production-shaped component**, not a +throwaway. + +## Files + +``` +codepet/Views/Copilot/Mocks/ + ChatMockData.swift // fixtures: a mock Company + [CopilotMessage] per scenario + [AgentRun] + ChatUserMock.swift // #Preview — working with the user + ChatRoadmapMock.swift // #Preview — working with the roadmap + ChatTasksMock.swift // #Preview — working with tasks + ChatEnvMock.swift // #Preview — setting up the environment + AgentsWorkingMock.swift // #Preview — parallel agents working +codepet/Views/Copilot/ + AgentsWorkingRow.swift // NEW real component + AgentRun model +codepetTests/ + AgentsWorkingRowTests.swift // pure-logic tests (status/elapsed/step math) +``` + +### Shared mock harness + +`ChatMockData` exposes a small helper that renders a fixture conversation the same +way `CopilotChatView.messageList` does — a `ScrollView` of `CopilotBubble`s inside +the chat column width (`760`) with the gutter (`24`), the top/bottom padding +(`40`/`24`), and inter-message spacing (`24`) matching the just-shipped spacing +pass. Each mock view is a thin wrapper: build the fixture array, hand it to the +harness, inject a mock `CompanyStore` via `.environmentObject`. This keeps all +five previews consistent and avoids each one re-deriving layout. + +Fixtures use **real companion ids and department names** so avatars/tints resolve: +companions `byte` (displays "Codepet"), `nova`, `crash`, `luna`, `sage`, `glitch`, +`null`; departments `eng` Engineering, `design` Design, `mkt` Marketing, +`sales` Sales, `support` Support, `fin` Finance, `ops` Operations, `legal` Legal. + +## The four scenario mocks + +Each is a realistic short transcript, not a single bubble. + +### 1. Working with the user (`ChatUserMock`) +- `me`: "Which task should I run right now?" +- `companion` (host, byte): a guidance answer, with the copy / regenerate / 👍👎 + action bar (present because `text` is non-empty). +- `me`: "draft it" +- `companion`: a follow-up carrying a `firstRunAction` ("Do it with me: …") so the + action button state is exercised. + +### 2. Working with the roadmap (`ChatRoadmapMock`) +- `me`: "What's next on the roadmap?" +- `companion`: summarizes the current phase + what's blocking launch (plain text). +- `companion`: a `navChip` message → "Go to Roadmap" (exercises the tap-to-navigate + chip; in the mock the tap is a no-op via the mock store). + +### 3. Working with tasks (`ChatTasksMock`) +- `me`: "Run the landing page copy task." +- `companion`: a `producing` message with `execSteps` mid-run (some `done`, one + in-flight) → renders the single-agent `ExecLogRow`. +- `companion`: a resolved `draft` (`Deliverable`, kind with an icon) → `draftCard` + with Approve / Redo / Revise chips. +- A second `draft` message with `draftApproved = true` → the "Added to Library" + state. + +### 4. Setting up the environment (`ChatEnvMock`) +- `me`: "Help me set up my tools." +- `companion`: a `setupSuggestion` (`SetupAction` resolving to a real `Toolkit` + item) → `setupCard` with the category enable verb. +- `companion`: a `noted` message (`[RememberedFact]`) → the "Noted" chip, shown + after a tool is enabled. + +## The new component: `AgentsWorkingRow` + +The inline, in-chat view of **multiple agents working at once**. Stacks one row per +active agent; visually a sibling of `ExecLogRow` but multi-agent and Codex-shaped. + +### Data model + +```swift +enum AgentRunStatus: Equatable { case working, reviewing, done, failed } + +struct AgentRun: Identifiable, Equatable { + let id: String + let companionId: String // resolves avatar + accent via PetCharacter.all + let deptName: String // "Engineering", "Design", … + let taskTitle: String // "Building the waitlist API" + var steps: [ExecStep] // reuses the existing ExecStep type + var status: AgentRunStatus + let startedAt: Date // for elapsed display +} +``` + +Derived (pure, testable) values live in an `AgentRunMath`/computed helpers, not in +the view: +- `stepCounter` → "4/7" (done count / total). +- `currentStepIndex` → first not-`done` step (the spinning one). +- `elapsedString(now:)` → "2:14" from `startedAt` (injected `now` so it's testable + and preview-stable — no `Date()` inside the view). +- `status` drives the pill label + color: Working (accent), Reviewing (gold), + Done (teal), Failed (red). + +### Layout (per agent row) + +``` +🟣 Codepet · Engineering ◐ Working · 2:14 4/7 + Building the waitlist API + ✓ Scaffold route ✓ Schema ◐ Handler · Tests … +🔵 Luna · Design ◐ Working · 0:48 2/5 + Landing hero visual pass + ✓ Moodboard ◐ Layout · Type · Export … +🟢 Sage · Legal ✓ Done · 1:02 5/5 + Privacy policy draft +``` + +- **Avatar**: `CompanionAvatar(companionId:size:28,isWorking:)` — animated only when + `status == .working`, matching `ExecLogRow`. +- **Header**: `Name · Department` in the companion's accent (reuses the existing + persona name/color lookup). +- **Status pill**: small capsule, `AgentRunStatus`-tinted. +- **Elapsed** + **step counter** right-aligned, like Codex. +- **Step checklist**: compact — done steps get a teal check, current spins, pending + sit dim. Reuses the `ExecLog` step visual language so it reads as the same family. +- Container: one `MessageCard` (or a light group) holding the stacked rows, so it + reads as a single "agents at work" block in the transcript, left-aligned like a + companion message. Respects Reduce Motion (static avatar + no spin). + +### `AgentsWorkingMock` +A `#Preview` feeding 3 `AgentRun`s — two `.working` (different depts, different +step progress) and one `.done` — with a fixed injected `now` so elapsed times are +stable across preview reloads. + +## Testing + +`AgentsWorkingRowTests` (pure logic, no UI): +- `stepCounter` for 0/N, partial, all-done. +- `currentStepIndex` picks the first not-done step; nil when all done. +- `elapsedString(now:)` formats mm:ss correctly (e.g. 134s → "2:14", 8s → "0:08"). +- `status`→pill (label + which color token) mapping is exhaustive over the enum. + +The four scenario mocks are `#Preview`-only (visual review), not unit-tested — they +carry no logic beyond fixture construction. + +## Follow-on (out of scope, noted for the plan) + +1. Wire `AgentsWorkingRow` into the live chat: a source of truth for concurrent + `AgentRun`s on `CompanyStore`, appended/updated as real runs start/step/finish. +2. Engine support for actually running department agents in parallel. +3. Optional: promote the mock harness into a shared dev "Chat mocks" gallery if we + want them reachable in a running debug build (today they're Xcode previews). diff --git a/docs/superpowers/specs/2026-07-28-composer-chips-states-design.md b/docs/superpowers/specs/2026-07-28-composer-chips-states-design.md new file mode 100644 index 0000000..ff925fd --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-composer-chips-states-design.md @@ -0,0 +1,99 @@ +# Composer — department chips + state expression + companion-tinted send — design spec + +**Date:** 2026-07-28 +**Target:** `My-Outcasts/codepet` (native macOS SwiftUI), branch `feat/chat-redesign` (PR #39) +**Adapts:** Phase 2 chat spec §7, reshaped for `feat/chat-redesign`. +**Status:** Design agreed with the founder (Q&A 2026-07-28); ready for spec review → plan. + +## Goal + +Make the composer express what it can do and what state it's in: **department chips** that genuinely refocus the answer, a **companion-tinted send**, and visible **idle / focused / busy** states — without adding any fake affordance. + +## Decisions (locked with the founder) + +- **Department chips are REAL via grounding scope:** selecting a department focuses the grounding `context` sent to the companyChat CF on that department (client-side; no CF change). No selection = today's behaviour exactly (additive). +- **Keep the existing `+` quick-actions menu as-is** — no fake "attach" button (the app has no file attachments; the redesign already chose quick-actions here). +- **State expression** (idle/focused/busy) and **companion-tinted send** are included (safe, real). + +## Deviations from §7 (deliberate) + +- **`+` stays a quick-actions menu**, not §7's attach (brief/deliverable/file) — file attach would be fake here. +- **The `Ask / Plan / Build` mode menu stays** (a real client-side feature the redesign shipped); §7 omitted it. +- **Dept chips scope grounding, they don't "route" the turn to a department backend** — there is no per-department chat CF; grounding-scope is the honest, CF-free way to make the chip real. + +## Non-goals + +- No Cloud Function / schema / new-dependency change. No file attachments. No change to the orb/cards/message rendering/backdrop/column. + +## Design + +### 1. Grounding scope — `codepet/Models/ChatContext.swift` + +Add `focusDepartment: Department? = nil` to `compose(...)`. When set, insert (right after the brief part, before roadmap) a focus directive so the model prioritizes that department: + +```swift +if let dep = focusDepartment { + parts.append("The founder is focused on the \(dep.name) department right now — " + + "prioritize \(dep.name) in your answer: \(dep.focus)") +} +``` + +The existing full department block still follows (context isn't removed — the answer is *focused*, not blinkered). Pure + deterministic; when `nil`, `compose` output is byte-identical to today. + +### 2. Thread the selection — `codepet/Managers/CompanyStore.swift` + +`sendChat` and `sendMessage` gain `department: Department? = nil`, passed straight into the `ChatContext.compose(...)` call in `sendMessage` (line ~479) as `focusDepartment: department`. Every existing caller (`walkThroughTask`, the run-task path, plain sends) keeps today's behaviour by omitting the argument (defaults to nil). The run-task compose call (line ~727) is unchanged. + +```swift +func sendChat(_ raw: String, language: AppLanguage, department: Department? = nil) async { … sendMessage(text, language: language, department: department) } +private func sendMessage(_ text: String, language: AppLanguage, department: Department? = nil) async { … ChatContext.compose(…, query: …, focusDepartment: department) … } +``` + +### 3. Composer UI — `codepet/Views/Copilot/ChatComposer.swift` + +New inputs (provided by `CopilotChatView`, which has the store): +- `var accent: Color` / `var accent2: Color` — the active companion's two hues (for send + focus border). +- `var isBusy: Bool` — `isCompanionTyping || isStreaming`. +- `@Binding var selectedDept: Department?`. + +**Department chip row** — a new row between the `TextField` and the existing control row. Render the **first 3** of `DepartmentCatalog.all` as toggle chips (2-letter `ab` badge + short name), plus an **overflow `Menu`** (`•••`) listing the remaining departments. Tapping a chip toggles `selectedDept` (tap the selected one to clear). Selected chip: filled `dep.accent.opacity(0.15)` + `dep.accent` border + `dep.accent` text; unselected: `hairline` border + `bodyText`. Overflow menu items set `selectedDept`; a check marks the current one. + +**Control row unchanged in structure:** `quickActionsMenu` (`+`), `modeMenu` (`Ask ▾`), `Spacer`, `sendButton`. + +**State expression:** +- **Idle** (not focused, not busy): current resting look, but the border/glow use the companion `accent` instead of the fixed purple→pink gradient. +- **Focused** (`focus.wrappedValue == true`): border brightens to `accent` and gains a soft outer `accent` glow (the existing `.shadow` becomes focus-gated + companion-tinted, respecting reduce-transparency). +- **Busy** (`isBusy`): the whole card `.opacity(0.72)` and the send button fills `mutedText` (regardless of `canSend`). + +**Companion-tinted send:** the send button's `canSend` gradient changes from `[accentPurple, accentPink]` to `[accent, accent2]`; the disabled/busy fill stays `mutedText`. The `arrow.up` + shape/size unchanged. + +### 4. Wiring — `codepet/Views/Copilot/CopilotChatView.swift` + +- Add `@State private var selectedDept: Department?`. +- Compute `accent = PetCharacter.all[companyStore.company.companionId]?.color ?? CodepetTheme.accentPurple` and `accent2 = …?.secondColor ?? CodepetTheme.accentPink`. +- Pass `accent`, `accent2`, `isBusy: companyStore.isCompanionTyping || companyStore.isStreaming`, and `selectedDept: $selectedDept` into the single `composerView` (used in both empty + docked placements). +- In `send()`, pass the selection: `companyStore.sendChat(text, language: lang, department: selectedDept)`. (Quick-actions/`runQuickAction` may keep passing nil, or also pass `selectedDept` — pass `selectedDept` there too for consistency.) +- The selected department **persists** across sends for the session (a `@State`, not cleared on send) — a founder working on Marketing stays focused on Marketing until they clear the chip. + +## Testing + +- **`ChatContextFocusTests` (new, pure):** `compose(..., focusDepartment: dep)` contains "focused on the \(dep.name) department" and `dep.focus`; `compose(..., focusDepartment: nil)` does NOT contain "focused on the" and equals the pre-existing output for the same inputs (guard against drift). Use a `DepartmentCatalog.all.first!` fixture. +- **Build gate:** foreground `xcodebuild build … CODE_SIGNING_ALLOWED=NO` → BUILD SUCCEEDED. Full suite 0 real failures + the new tests. +- **No unit test** for the composer UI or the send-threading (view/I-O) — verified by build + the signed-build visual pass. +- **Signed-build visual pass:** dept chips render (3 + overflow), toggle + tint on select; focusing the field brightens the border to the companion hue with a glow; while a reply streams the card dims + send greys; send is the companion's colors; the `+` quick-actions + `Ask ▾` still work; and selecting a dept then asking a question yields a visibly department-focused answer. + +## Files + +**New:** `codepetTests/ChatContextFocusTests.swift` +**Modified:** `codepet/Models/ChatContext.swift` (focus param), `codepet/Managers/CompanyStore.swift` (`sendChat`/`sendMessage` department param), `codepet/Views/Copilot/ChatComposer.swift` (chips + state + tinted send + new inputs), `codepet/Views/Copilot/CopilotChatView.swift` (selectedDept + accents + isBusy + threading). + +## Risks / watch-items + +- **Composer height:** the new chip row adds vertical height to the composer (both empty hero + docked). Confirm it doesn't crowd the empty-state hero on a small window — the chips may need to be a single scrollable line if they wrap. +- **`ChatComposer` previews** don't have the new inputs — update the `#Preview` host(s) with sample `accent`/`accent2`/`isBusy`/`selectedDept`. +- **Grounding-scope honesty:** the focus directive must genuinely reach the model (verify the `focusDepartment` is threaded all the way to the CF `context` string) — otherwise the chip is decorative. The pure test asserts the directive is in the composed string; the visual pass confirms the answer shifts. +- **`compose` default-path parity:** the `nil` branch must not change the existing output (a test pins this). + +## Rollout + +Implement on `feat/chat-redesign` → build + suite green → build & launch signed for the founder's visual sign-off → push (rebasing over concurrent commits). Nothing merges (branch held). Follow up on the W1 tracker's "Composer UX" task. diff --git a/docs/superpowers/specs/2026-07-28-landing-state-design.md b/docs/superpowers/specs/2026-07-28-landing-state-design.md new file mode 100644 index 0000000..db513ef --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-landing-state-design.md @@ -0,0 +1,101 @@ +# First-run / empty chat state — live landing — design spec + +**Date:** 2026-07-28 +**Target:** `My-Outcasts/codepet` (native macOS SwiftUI), branch `feat/chat-redesign` (PR #39) +**Adapts:** Phase 2 chat spec §5 (ChatLandingState) + §6 (landing view), reshaped for `feat/chat-redesign`. +**Status:** Design agreed with the founder (Q&A 2026-07-28); ready for spec review → plan. + +## Goal + +Turn the empty chat state from four static "send a canned message" capability cards into a **live landing**: a pure `ChatLandingState` drives the greeting + up to **three live roadmap cards** (the beacon, needs-you count, awaiting-approval count). When there's no roadmap yet, it falls back to **three prompt-starters that fill the composer**. + +## Decisions (locked with the founder) + +- **Live cards replace the static capability cards.** Up to 3, each omitted when its count is zero / beacon is nil: **DO THIS NEXT {beacon title}**, **NEEDS YOU {n}**, **AWAITING APPROVAL {n}**. Tapping any opens the Roadmap page (`companyStore.select(.roadmap)`). +- **Empty fallback = 3 prompt-starters** ("Draft my positioning", "Plan this week", "Review my brief") that **insert their text into the composer** (populate the draft; the founder edits/sends) — they do NOT fire immediately. +- The composer's own `+` quick-actions menu (Run a task / etc.) is **unchanged** — only the empty-state CARDS change. + +## Non-goals + +- No Cloud Function / schema / dependency change. No change to the orb, composer internals, cards grammar, message rendering, or column width. +- No new roadmap generation trigger here (roadmap population is owned elsewhere). + +## Design + +### 1. `ChatLandingState` — `codepet/Models/ChatLandingState.swift` (new, pure) + +SwiftUI-free, deterministic given `now`; follows `RoadmapEngine`/`ChatContext` conventions. + +```swift +struct ChatLandingState { + let greeting: String // "Good evening, Mona." (time-of-day + founder; "there"/"bạn" if blank) + let question: String // "What should we build for {company} today?" + let beacon: RoadmapTask? // RoadmapEngine.nextStep(company.tasks) + let needsYouCount: Int // status == .needsYou, EXCLUDING the beacon + let awaitingApprovalCount: Int // status == .needsApproval + let isEmpty: Bool // company.tasks.isEmpty → show prompt starters + + init(company: CompanyState, now: Date, language: AppLanguage) +} +``` + +- **Greeting** (moved out of `CopilotChatView.greetingLine1/2`, copy unchanged): hour from `Calendar.current.component(.hour, from: now)` — `<12` morning / `<18` afternoon / else evening (localized en/vi); founder = `company.brief.founderName` trimmed, else "there"/"bạn". `greeting = "\(part), \(founder)."` +- **Question**: company = `company.brief.projectName` trimmed, else "Codepet"; `question = "What should we build for \(company) today?"` (vi: "Hôm nay mình xây gì cho \(company)?"). +- **beacon** = `RoadmapEngine.nextStep(company.tasks)`. +- **needsYouCount** = `company.tasks.filter { RoadmapEngine.status(for: $0, in: company.tasks) == .needsYou && $0.id != beacon?.id }.count`. +- **awaitingApprovalCount** = `company.tasks.filter { RoadmapEngine.status(for: $0, in: company.tasks) == .needsApproval }.count`. +- **isEmpty** = `company.tasks.isEmpty`. + +### 2. `ChatEmptyState` rework — `codepet/Views/Copilot/ChatEmptyState.swift` + +Replace the inputs `line1/line2/quickActions/onQuickAction` with: +```swift + let state: ChatLandingState + let onOpenRoadmap: () -> Void + let onStarter: (String) -> Void + @ViewBuilder var composer: Composer +``` +- **Greeting block** unchanged visually: `state.greeting` on line 1 (primary text), `state.question` on line 2 (purple→pink gradient). (Same fonts/layout as today.) +- **Card row** (replaces the 2-column `QuickAction` grid): + - **`state.isEmpty == true`** → three **starter cards**, each a tappable tinted card showing the starter label; tap → `onStarter()`. Localized starters: en `["Draft my positioning", "Plan this week", "Review my brief"]`, vi `["Soạn định vị của tôi", "Lên kế hoạch tuần này", "Xem lại bản tóm tắt"]`. + - **else** → up to three **live cards**, built in this order, each omitted when empty: + | Card | Shown when | Eyebrow (en/vi) | Value | Hue | Tap | + |---|---|---|---|---|---| + | Beacon | `state.beacon != nil` | DO THIS NEXT / TIẾP THEO | `beacon.title` | companion accent | `onOpenRoadmap()` | + | Needs you | `state.needsYouCount > 0` | NEEDS YOU / CẦN BẠN | `"\(count)"` | `accentBlue` | `onOpenRoadmap()` | + | Awaiting approval | `state.awaitingApprovalCount > 0` | AWAITING APPROVAL / CHỜ DUYỆT | `"\(count)"` | `accentGold` | `onOpenRoadmap()` | + - Card visual reuses today's tinted-card style (surface fill + hairline + a leading same-hue accent bar), with an uppercase eyebrow label above the value; keep the existing `maxWidth: 600` grid cap and 2-column layout (beacon can span or sit first — a simple 2-col `LazyVGrid` as today is fine). +- The `card(_:)`/`cards` for `QuickAction` are replaced by the above; the `QuickAction` type stays (still used by the composer's `+` menu). + +### 3. Wiring — `codepet/Views/Copilot/CopilotChatView.swift` + +- Build the state each render: `ChatLandingState(company: companyStore.company, now: Date(), language: lang)` and pass it to `ChatEmptyState`. +- `onOpenRoadmap = { companyStore.selectedDeptKey = nil; companyStore.select(.roadmap) }`. +- `onStarter = { draft = $0; inputFocused = true }` (populate the composer draft + focus; does NOT send). +- Remove `greetingLine1`/`greetingLine2` (now in `ChatLandingState`); remove `founderName`/`companyName` if they become unused (build confirms). The composer's `quickActions`/`runQuickAction` STAY (feed the `+` menu). + +## Testing + +- **`ChatLandingStateTests` (new, pure):** + - Greeting hour boundaries via injected `now` built in `Calendar.current`: hour 11 → "Good morning", 12 → "Good afternoon", 17 → "Good afternoon", 18 → "Good evening". + - Founder name present → included; blank → "there" (en) / "bạn" (vi). + - `question` includes the project name; blank project → "Codepet". + - From a fixture `[RoadmapTask]`: `beacon` == `RoadmapEngine.nextStep`; `needsYouCount` excludes the beacon; `awaitingApprovalCount` counts `.needsApproval`; `isEmpty` true iff tasks empty. +- **Build gate:** foreground build → BUILD SUCCEEDED; full suite 0 real failures + new tests. +- **Signed-build visual pass:** with a roadmap → DO THIS NEXT shows the beacon title, plus NEEDS YOU / AWAITING APPROVAL counts when >0; tapping opens the Roadmap page. With no roadmap → three starter cards that populate the composer on tap (not send). Greeting reads correctly; light + dark. + +## Files + +**New:** `codepet/Models/ChatLandingState.swift`, `codepetTests/ChatLandingStateTests.swift` +**Modified:** `codepet/Views/Copilot/ChatEmptyState.swift` (inputs + card row + preview), `codepet/Views/Copilot/CopilotChatView.swift` (build+pass state, handlers, remove greeting computeds). + +## Risks / watch-items + +- **`ChatEmptyState` preview** must construct a `ChatLandingState` fixture (needs a `CompanyState` — use `.empty` or a small fixture) + the new handlers. +- **Greeting parity:** the copy must match today's exactly (moved, not rewritten) so the empty state doesn't visibly change wording. +- **Card legibility over the backdrop:** reuse the opaque-surface tinted card (same fix as the card grammar) so live cards read in light mode. +- **`select(.roadmap)`** is the real nav; confirm `.roadmap` is the correct `AppView` case (it is — used by the sidebar/workspace). + +## Rollout + +Implement on `feat/chat-redesign` → build + suite green → build & launch signed for the founder's visual sign-off → push (rebasing over concurrent commits). Nothing merges (branch held). Follow up on the W1 tracker's "First-run / empty chat state" task. diff --git a/docs/superpowers/specs/2026-07-28-luminous-orb-streaming-design.md b/docs/superpowers/specs/2026-07-28-luminous-orb-streaming-design.md new file mode 100644 index 0000000..f456164 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-luminous-orb-streaming-design.md @@ -0,0 +1,147 @@ +# Luminous companion orb + streaming affordance — design spec + +**Date:** 2026-07-28 +**Target:** `My-Outcasts/codepet` (native macOS SwiftUI), branch `feat/chat-redesign` (PR #39) +**Adapts:** Phase 2 chat spec (`docs/chat-surface-phase2-spec`) §1 (second hue), §2 (orb), §4 (tokens), §8 (streaming). That spec targets a parallel branch (`CopilotChatView` + a new `CompanionOrbView`); this one reshapes the same design for `feat/chat-redesign`, which already has a `CompanionOrb` and orb-avatar bubbles. +**Status:** Design agreed with the founder (Q&A 2026-07-28); ready for spec review → plan. + +## Goal + +Make the companion orb the chat's real identity — a luminous, companion-tinted sphere instead of today's flat angular-gradient circle — and turn the streaming state from static text ("Thinking…") into an expressive, honest affordance: a breathing orb + a label that names the work when there is work to name. + +## Decisions (locked with the founder) + +- **Scope:** the luminous orb (§2) **and** the streaming state (§8) together, which pulls in the per-companion second hue (§1) and the two theme tokens (§4) the orb needs. Aura field (§3), message un-bubble (§9), and card grammar (§10) are **out of scope** here. +- **Idle-reply copy:** when there is no task to name, the label reads **"Working on it…"** (vi: "Đang xử lý…"). +- **Named work:** when a task title is available, the label reads **"Drafting {title}…"** (vi: "Đang soạn {title}…"). +- **Shimmer:** keep §8's subtle ~2.1s light-sweep through the label; disabled under Reduce Motion. + +## Deviations from the Phase 2 spec (deliberate, to fit this branch) + +- **Reimplement the existing `CompanionOrb` in place** rather than adding a new `CompanionOrbView`. Keep its current API (`size: CGFloat`, `glow: Bool`) so the 4 existing call sites (hero 78; avatars/typing/producing 28) don't churn; **add** `isWorking: Bool = false`. (Spec's `Size` enum {hero/inline/avatar} is not adopted — raw `size` already works here.) +- Companion hues are read from the environment store (per spec intent) so switching companion re-tints every orb with no call-site change. + +## Non-goals + +- No aura field (§3), no message un-bubble (§9), no card grammar (§10). +- No Cloud Function / `RoadmapEngine` / Firestore changes. No new dependencies. No literal hex in views except inside `CompanionOrb`/tokens (the orb's internal core stops, which are its own art). + +## Design + +### 1. Companion second hue — `codepet/Models/Character.swift` + +`PetCharacter` has `hexColor` + `color`. Add `let secondHexColor: String` and `var secondColor: Color { Color(hex: secondHexColor) }`, and a `secondHexColor:` argument to all seven entries in `static let all` (hand-picked, not hue-rotated): + +| id | `hexColor` (existing) | `secondHexColor` (new) | +|---|---|---| +| byte | `#8B7BE8` | `#4EC9D4` | +| nova | `#FF8C00` | `#6EA8FF` | +| crash | `#E04040` | `#F0A860` | +| luna | `#5B8DEF` | `#C99BF0` | +| sage | `#20B090` | `#FDC352` | +| glitch | `#E0508C` | `#5AD0E0` | +| null | `#80C830` | `#5AD0E0` | + +`hexColor` is read from the static catalog at runtime and never persisted/decoded, so adding a field needs no migration. + +### 2. Theme tokens — `codepet/Views/CodepetTheme.swift` + +Add alongside the existing `Color.dyn` tokens: + +```swift +static let chatCanvas = Color.dyn("#f8f7f3", "#16130f") // matches pageBackground +static let chatOrbCore = Color.dyn("#0E0A16", "#040208") // the orb's luminous core +``` + +`chatCanvas` is added for parity with the Phase 2 token set; the orb consumes `chatOrbCore`. (`chatCanvas` is not otherwise wired in this scope — it lands so §3/§9 can use it later without a second edit. If the plan's self-review flags it as unused-in-scope, keep it — it's a documented forward token.) + +### 3. Luminous orb — reimplement `codepet/Views/Copilot/CompanionOrb.swift` + +Replace the flat angular-gradient body with a luminous sphere that reads as **emitted light**, companion-tinted. Keep the type name and API; add `isWorking`: + +```swift +struct CompanionOrb: View { + var size: CGFloat = 78 + var glow: Bool = true // outer bloom (kept for the 4 existing call sites) + var isWorking: Bool = false // NEW — drives the breathe + @EnvironmentObject var companyStore: CompanyStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion + // hue1 = active companion .color, hue2 = .secondColor (fallback accentPurple/accentPink) +} +``` + +Hues: resolve `PetCharacter.all[companyStore.company.companionId]` → `.color` / `.secondColor`; fall back to `accentPurple` / `accentPink` if absent. + +Composition (all clipped to the circle so the edge stays crisp), in order: +1. near-black core radial (`chatOrbCore` light→dark) so colour reads as luminous +2. three internal colour bands — `hue1`, `hue2`, and `hue1` lifted ~45% toward white — drifting on **different periods** (×0.83, ×1.17, ×0.61) so the flow never visibly loops +3. a specular crescent near −34%/−40% of the radius, drifting slightly +4. inner base shading for sphericality +5. a rim, brightest where the specular sits +6. a soft outer bloom (gated on `glow` and not Reduce Transparency) + +Steps 2–3 composite additively (`GraphicsContext.BlendMode.plusLighter` inside a `drawLayer`). + +**Rendering:** `TimelineView(.animation)` driving a `Canvas`. **Motion rule:** the orb drifts internally at rest; **only `isWorking` changes scale** — a 3.6s breathe between 1.0 and 1.07. Scale means "working," nothing else. + +**Reduce Motion:** render one static frame — no `TimelineView`, no breathe, no drift. Composition still reads as a sphere. + +**Performance is the schedule risk.** If the internal flow can't hold ~60fps at the hero size, the fallback is a Metal shader via `.layerEffect`; the plan budgets for that rather than discovering it late. Verified only on a real signed build (no agent here can see the screen). + +**Call sites unchanged** except the thinking row (below) passes `isWorking: true`. The two `#Preview`s (this file + `ChatEmptyState`) must inject `.environmentObject(CompanyStore())` since the orb now reads the store. + +### 4. Streaming label logic — `codepet/Models/ChatThinkingLabel.swift` (new, pure) + +SwiftUI-free and unit-tested, following `RoadmapEngine`/`TopbarCounts`: + +```swift +enum ChatThinkingLabel { + /// title != nil → "Drafting {title}…"; nil → "Working on it…". Localized en/vi. + static func text(taskTitle: String?, language: AppLanguage) -> String +} +``` + +- `taskTitle == nil` → en "Working on it…" / vi "Đang xử lý…" +- `taskTitle == "X"` → en "Drafting X…" / vi "Đang soạn X…" + +Honesty: a title is passed only when a real one exists (a producing deliverable / in-flight run); a plain chat turn passes `nil`. Never invent a task name. + +### 5. Streaming row — `codepet/Views/Copilot/ChatThinkingRow.swift` (new) + +Replaces both `CopilotChatView.typingRow` and `CopilotBubble.producingRow` with one view: + +```swift +struct ChatThinkingRow: View { + let taskTitle: String? // nil for a plain chat turn + // orb + shimmering label, left-aligned, Spacer trailing (matches today's rows) +} +``` + +- **Orb:** `CompanionOrb(size: 28, glow: false, isWorking: true)` — breathing. +- **Label:** `ChatThinkingLabel.text(taskTitle:language:)`, `inter(13)`, `mutedText`. +- **Shimmer:** a brighter gradient masked over the label text, animated left→right on a **2.1s** linear `TimelineView`. Under `accessibilityReduceMotion`, render the label plain (no sweep) and the orb static. + +**Wiring in `CopilotChatView`:** +- `typingRow` (chat turn, `isCompanionTyping`) → `ChatThinkingRow(taskTitle: nil)` → "Working on it…". +- `producingRow` (`CopilotBubble`, `message.producing`) → `ChatThinkingRow(taskTitle: )`. The plan resolves the exact source; if none is readily on the message, pass `nil` (→ "Working on it…") — do **not** fabricate a title. + +## Testing + +- **`ChatThinkingLabelTests` (new, pure):** `taskTitle: nil` → "Working on it…" (en) / "Đang xử lý…" (vi); `taskTitle: "positioning brief"` → "Drafting positioning brief…" (en) / "Đang soạn positioning brief…" (vi). +- **Build gate:** foreground `xcodebuild build … CODE_SIGNING_ALLOWED=NO` → BUILD SUCCEEDED. Full suite stays at 303 real passes (the `CompanyStoreScaffordOnboardingTests` Firebase-init flake is the known non-regression). +- **Visual, human-verified on a signed build (built + launched for the founder):** orb reads as luminous & companion-tinted (byte = violet→teal); breathe only when working; shimmer subtle; Reduce Motion → static orb + plain label; hero (78) performs at 60fps. + +## Files + +**New:** `codepet/Models/ChatThinkingLabel.swift`, `codepet/Views/Copilot/ChatThinkingRow.swift`, `codepetTests/ChatThinkingLabelTests.swift` +**Modified:** `codepet/Models/Character.swift` (§1), `codepet/Views/CodepetTheme.swift` (§2), `codepet/Views/Copilot/CompanionOrb.swift` (§3 — reimplemented), `codepet/Views/Copilot/CopilotChatView.swift` (§5 wiring + delete `typingRow`/`producingRow`), and the `ChatEmptyState` preview (inject store). + +## Risks / watch-items + +- **Orb performance** at hero size (Canvas/`plusLighter`/TimelineView) — the schedule risk; Metal `.layerEffect` fallback budgeted. Verify on the signed build. +- **Env coupling:** `CompanionOrb` now needs `CompanyStore` in the environment. All 4 runtime call sites already have it; only previews need the injection. +- **Reduce Motion** must fully static the orb (no TimelineView) — verify the flag path, not just the animated one. + +## Rollout + +Implement on `feat/chat-redesign` → build + suite green → build & launch signed for the founder's visual sign-off → push. Nothing merges (branch held). Follow up on the W1 tracker's "Streaming/typing affordance" task. diff --git a/docs/superpowers/specs/2026-07-28-message-rendering-feedback-design.md b/docs/superpowers/specs/2026-07-28-message-rendering-feedback-design.md new file mode 100644 index 0000000..6b4affb --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-message-rendering-feedback-design.md @@ -0,0 +1,126 @@ +# Message rendering (un-bubble) + per-message feedback — design spec + +**Date:** 2026-07-28 +**Target:** `My-Outcasts/codepet` (native macOS SwiftUI), branch `feat/chat-redesign` (PR #39) +**Adapts:** Phase 2 chat spec §9, reshaped for `feat/chat-redesign`. +**Status:** Design agreed with the founder (Q&A 2026-07-28); ready for spec review → plan. + +## Goal + +Make the transcript read like a modern assistant chat: the **assistant messages lose their bubble** and sit on the canvas beside the companion orb; the **user message keeps its purple bubble** but gains a chat "tail"; and each assistant message gets **per-message feedback** — Copy (exists), Regenerate (exists), and a new **thumb up / down** that records a reaction to the existing `feedback` collection. + +## Decisions (locked with the founder) + +- **Full §9 this pass:** un-bubble assistant + keep Copy/Regenerate + add thumb up/down (a real `feedback`-collection write via a small store method — no schema/CF change). +- **User bubble:** keep the redesign's filled **accent-purple** bubble; adopt an asymmetric radius (a "tail"), not surface+hairline. + +## Deviations from §9 (deliberate) + +- **No rename** of `CopilotChatView` → `ChatView` (§9 mentions it) — that's file-reorg churn for a parallel branch; risky here for zero user value. Skip. +- **User bubble stays purple** (redesign choice the founder kept), not §9's surface+hairline. +- **No `MarkdownBlocks`** — this branch renders assistant text as plain `Text` (now `inter(15)`); §9's "a bubble fights the markdown" rationale is moot, but un-bubbling still reads cleaner. Not adding markdown rendering here. + +## Non-goals + +- No `CopilotMessage`/schema change; no Cloud Function change; no new dependencies. +- Reaction selection is **view-local UI state** (not persisted on the message) — the durable artifact is the `feedback` doc that gets written. (Persisting the reaction on the message is a future item.) +- Not touching the composer, empty state, orb, card grammar, backdrop, or column width. + +## Design + +### 1. User (me) bubble — `CopilotBubble.textBubble` (me branch), `CopilotChatView.swift` + +Keep the `CodepetTheme.accentPurple` fill and white `inter(15)` text; replace the symmetric `RoundedRectangle(cornerRadius: 14)` with an asymmetric shape so the bottom-trailing corner is tight (a tail toward the user's side): + +```swift +.background( + UnevenRoundedRectangle( + cornerRadii: .init(topLeading: 14, bottomLeading: 14, bottomTrailing: 4, topTrailing: 14), + style: .continuous + ).fill(CodepetTheme.accentPurple) +) +``` + +(macOS 13+ `UnevenRoundedRectangle`; the app deploys well above that.) Alignment/spacing unchanged. + +### 2. Assistant (companion) message — un-bubble, `CopilotBubble.textBubble` (companion branch) + +Remove the surface `RoundedRectangle(cornerRadius: 14).fill(CodepetTheme.surface)` background from the companion text. The text (`inter(15)`, `primaryText`) sits directly on the canvas. Keep: +- the leading `CompanionOrb(size: 28, glow: false)` avatar and the `HStack(alignment: .top, spacing: 10)` layout, +- the `companionActions` row below the text. + +Only the text's own background/padding change: drop the fill; keep a small vertical rhythm (the text keeps its natural padding within the VStack). Net effect: assistant replies read as open text next to the orb, not a boxed bubble. + +### 3. Per-message feedback — `companionActions` + a store write + +`companionActions` currently has **Copy** (`doc.on.doc`) and **Regenerate** (`arrow.clockwise`). Add **thumb up** (`hand.thumbsup`) and **thumb down** (`hand.thumbsdown`), same muted icon-button style, to the right of them. + +- View-local `@State private var reaction: Bool?` on `CopilotBubble` (nil = none, true = up, false = down). Tapping a thumb sets it and calls the store; the selected thumb renders **filled** (`hand.thumbsup.fill` / `.thumbsdown.fill`) and tinted `accentPurple`; the other stays muted outline. A second tap of the same thumb is allowed (re-sends); tapping the opposite flips it. +- Thumbs are shown on **companion messages only** (inside `companionActions`, which already renders only there). Not on `me`, not on cards, not on the thinking row. + +**Store method — `CompanyStore.reactToMessage(messageId:helpful:)`:** + +```swift +func reactToMessage(messageId: String, helpful: Bool) { + guard !AppEnvironment.isRunningTests, !ServerLoggingGate.isOptedOut else { return } + let data = MessageFeedback(messageId: messageId, helpful: helpful, + companyId: companyId, + userId: Auth.auth().currentUser?.uid ?? "anonymous", + companionId: company.companionId).firestoreData() + Firestore.firestore().collection("feedback").addDocument(data: data) { error in + if let error { print("[Feedback] chat reaction error: \(error.localizedDescription)") } + } +} +``` + +Fire-and-forget, guarded exactly like `FeatureFeedbackManager.submit` (tests + opt-out gate), create-only (matches the deployed `feedback` rules). Uses the store's existing `companyId` + `company.companionId`. (Add `import FirebaseFirestore` / `import FirebaseAuth` to the store if not already present.) + +### 4. Feedback payload — `codepet/Models/MessageFeedback.swift` (new, pure + testable) + +```swift +struct MessageFeedback: Equatable { + let messageId: String + let helpful: Bool + let companyId: String + let userId: String + let companionId: String + + /// The Firestore document body (server timestamp added by the writer, not here). + func firestoreData() -> [String: Any] { + [ + "kind": "chat_message", + "messageId": messageId, + "helpful": helpful, + "companyId": companyId, + "userId": userId, + "companionId": companionId, + "platform": "macos", + ] + } +} +``` + +Pure — no Firestore import; the `timestamp` (`FieldValue.serverTimestamp()`) is added by the store writer. `"kind": "chat_message"` distinguishes these from the feature-rating docs `FeatureFeedbackManager` writes. + +## Testing + +- **`MessageFeedbackTests` (new, pure):** `firestoreData()` for a known instance contains `kind == "chat_message"`, the exact `messageId`, `helpful` (test both true/false), `companyId`, `userId`, `companionId`, `platform == "macos"`, and does NOT contain a `timestamp` key (writer's job). Assert on the dictionary values (cast to String/Bool). +- **Build gate:** foreground `xcodebuild build … CODE_SIGNING_ALLOWED=NO` → BUILD SUCCEEDED. Full suite stays at 0 real failures + the new MessageFeedback tests. +- **No unit test for the write itself** (fire-and-forget Firestore I/O) or the un-bubble/radius (visual). Verified by: +- **Signed-build visual pass:** assistant replies read as open text beside the orb (no box); the user bubble is purple with the tight bottom-right corner; thumbs appear on assistant messages, fill on tap, and Copy/Regenerate still work. (Optionally confirm a `feedback` doc lands, but the write is best-effort.) + +## Files + +**New:** `codepet/Models/MessageFeedback.swift`, `codepetTests/MessageFeedbackTests.swift` +**Modified:** `codepet/Views/Copilot/CopilotChatView.swift` (`textBubble` me + companion; `companionActions` + `reaction` state), `codepet/Managers/CompanyStore.swift` (`reactToMessage` + imports if needed). + +## Risks / watch-items + +- **Reaction state is view-local** — it resets if SwiftUI recreates the row (e.g. aggressive scroll recycling). Acceptable for v1 (the write is the durable part); note it so it isn't mistaken for a bug. +- **Un-bubbled assistant text legibility** — with no surface panel, confirm the `primaryText` on the `ChatBackdrop` wash reads well in light + dark (it's the same context the card-grammar surface fix addressed; plain text should be fine). +- **`feedback` write auth** — `Auth.auth()` is only safe to call once Firebase is configured; the store runs post-configure in normal use, and the tests-gate short-circuits under XCTest, so the known Firebase-init flake is not worsened. Keep the `isRunningTests` guard. +- **Behavior:** Copy + Regenerate closures must stay byte-identical; only additive thumbs. + +## Rollout + +Implement on `feat/chat-redesign` → build + suite green → build & launch signed for the founder's visual sign-off → push (rebasing over concurrent commits). Nothing merges (branch held). Follow up on the W1 tracker's "Message / transcript visual design" task. diff --git a/docs/superpowers/specs/2026-07-28-parallel-agents-fanout-design.md b/docs/superpowers/specs/2026-07-28-parallel-agents-fanout-design.md new file mode 100644 index 0000000..f458c0a --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-parallel-agents-fanout-design.md @@ -0,0 +1,131 @@ +# Parallel department agents — live fan-out — design + +**Date:** 2026-07-28 +**Owner:** Mona +**Branch:** `feat/chat-redesign` +**Status:** Approved design → spec +**Builds on:** [[codepet-chat-mockups-agents-ui]] — the `AgentsWorkingRow` + `AgentRun` view layer already shipped; this wires it live and adds the concurrency engine. + +## Goal + +One tap fans a single prompt out to several department agents that run **in +parallel**, shown live in the chat via the existing `AgentsWorkingRow`, with each +agent's draft landing in the transcript as it finishes. + +## What the user sees + +1. Founder taps the **"Run my next moves"** quick-action chip in the composer. +2. The client planner picks the next actionable task in each of up to **3** + departments and seeds one `AgentRun` per task (status `.working`). +3. A single `AgentsWorkingRow` renders below the messages, showing all agents + working concurrently (per-agent avatar, `Name · Dept`, status pill, elapsed, + step counter, live checklist). +4. Each agent's `runTask` runs concurrently; as each completes, its row flips to + `.done` (or `.failed`) and its **draft `Deliverable` message** is appended to + the transcript. +5. When all finish, the `AgentsWorkingRow` clears; the drafts remain in the chat. + +## Scope / non-goals + +- **Client-side planner only.** Tasks are chosen from the existing roadmap; no + LLM and **no Cloud Function changes** (CF lives in the separate + `Murror/CodePet-Clean` repo — out of scope). +- **No real streamed steps.** Per-agent step progress reuses the existing + client-side reveal animation (the backend `RunTaskResponse` carries no steps). +- **No chat-while-fanning-out.** The composer is disabled during a fan-out (same + "busy" model as a single run today), just parallelized. Concurrent chat turns + + runs are out of scope. +- **Cap = 3** concurrent agents per fan-out (a named constant), to bound credit + spend and latency — each run counts separately against the server daily cap. + +## Architecture + +Everything lives in `CompanyStore` (`@MainActor`) + `CopilotChatView`. The +network layer (`RunTaskClient`) is already stateless and parallel-safe (one HTTP +POST per run); the only single-run constraints today are the scalar +`isCompanionTyping`/`isStreaming` flags and the single-`producingId` model in +`produceDraftInline`. We add a run *collection* alongside them rather than +touching that single-run path. + +### 1. State (new, on `CompanyStore`) +- `@Published var activeAgentRuns: [AgentRun] = []` — the live source for one + `AgentsWorkingRow`. Empty ⇒ no row shown. +- `@Published private(set) var isFanningOut: Bool = false` — serializes a fan-out + against the normal single chat turn. +- `static let maxFanOut = 3` — the cap. + +### 2. Planner (pure, testable) +`nextMovesPlan(limit: Int) -> [RoadmapTask]`: +- Walk `company.tasks` in roadmap order (phase order, then array position — the + same ordering `RoadmapEngine.nextStep` uses). +- Keep a task only if `RoadmapEngine.status(for:in: company.tasks) == .codepetCanDo` + **and** it has a `dept` that maps to a specialist via + `DepartmentCompanions.companionId(for:)`. +- Take the **first** such task **per distinct department** (one agent per dept), + stop at `limit`. +- Pure over `(tasks)` → unit-testable with fixtures. Returns `[]` when nothing is + actionable. + +### 3. Engine (`fanOutNextMoves(language:)`) +- Guard `!isFanningOut && !isCompanionTyping && !isStreaming` (don't start during + another turn). +- `let plan = nextMovesPlan(limit: Self.maxFanOut)`. If empty → append an honest + companion bubble ("You're all caught up — no open tasks I can run right now.") + and return. +- Capture `cid = company.id`. Set `isFanningOut = true`. +- Seed `activeAgentRuns`: for each planned task build an `AgentRun` + (`companionId`/`deptName` from `taskSpecialist(for:)`, falling back to the host + companion + dept display name; `steps` from the existing + `execSteps(task:specialist:decisionCount:language:)`; `status: .working`; + `startedAt: Date()`; a stable `id`). +- **Fan out with a `TaskGroup`** — one child per run: + - Kick off the existing client-side **step-reveal** for that run's `AgentRun` + (mark its steps `.done` one by one on the main actor). + - `await taskRunner(runRequest(for: task, language:))` concurrently. + - After the await, **re-check `cid == company.id`** (account-switch safety); if + changed, abort this branch silently. + - On success: set that `AgentRun.status = .done`, mark all its steps done, and + append the draft `Deliverable` message (reuse `buildDeliverable(from:task:)` + and the existing draft-message append shape). On `nil`: set `.failed` and + append one honest "couldn't finish " bubble. +- When the group completes: clear `activeAgentRuns`, set `isFanningOut = false`, + `flushActiveThread()`. +- All published mutations happen on the main actor (CompanyStore is `@MainActor`); + concurrency is via structured `TaskGroup`. + +### 4. UI wiring (`CopilotChatView`) +- **Chip:** add a **"Run my next moves"** entry to `quickActions`; its tap calls + `companyStore.fanOutNextMoves(language:)` (a new branch in `runQuickAction`, not + the text-send path). +- **Live view:** in `messageList`, after the `ForEach(chatMessages)` and the + existing typing row, render `AgentsWorkingRow(runs: companyStore.activeAgentRuns)` + when `!activeAgentRuns.isEmpty`. Auto-scroll on `activeAgentRuns` change (mirror + the existing `isCompanionTyping` scroll trigger). +- **Composer:** fold `isFanningOut` into `isChatBusy` / `canSend` so the composer + disables during a fan-out, consistent with a single run today. + +### 5. Cost & failure honesty +- The cap (`maxFanOut = 3`) bounds concurrent runs. +- A `nil` result (offline, error, or a server `429` daily-limit — `RunTaskClient` + fail-opens all of these to `nil`) → that agent's row goes `.failed` + an honest + bubble, instead of the current silent drop. + +## Testing +- **Planner** (`CompanyStoreFanOutTests` or a pure helper test): given fixture + `[RoadmapTask]`, `nextMovesPlan(limit:)` returns the first `codepetCanDo` task + per distinct dept, ordered by phase then position, capped at `limit`; skips + done/needsYou/blocked/needsApproval/unassigned-dept tasks; returns `[]` when + none actionable. If the planner needs company state, extract it as a pure static + over `(tasks, limit)` so it tests without the store. +- `AgentRun` status transitions already have math tests; add a check that a + `.failed` run still carries its (partial) steps. +- Engine orchestration (TaskGroup, appends, clearing) is exercised manually via + the running app + the existing mock gallery; not unit-tested (it's `@MainActor` + I/O orchestration). + +## Follow-on (out of scope) +1. LLM planner via a new CF endpoint (smarter, free-form fan-out). +2. Real streamed per-agent steps from the backend. +3. Chat-while-running (concurrent chat turn alongside live agents). +4. Surfacing the specific `429 daily_limit_reached` shape (needs `RunTaskClient` + to decode 429 rather than fail-open to `nil`).