diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift index 35508ad..c926b06 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift @@ -219,7 +219,10 @@ struct LoopWorkspaceView: View { HStack(spacing: 4) { ForEach(Array(store.layout.tabs.enumerated()), id: \.element.id) { index, tab in TabPillView( - title: agentTabTitle(for: tab), + title: Self.agentTabTitle( + loopType: store.node.loopType, + backend: store.node.backend, + launchesAgent: tab.primary.launchesClaudeCode), // Only the agent tab has a loop state to report — a plain shell is a shell. // This is the fix for "a background tab asked a question and nothing said so". state: tab.surfaces.contains(where: \.launchesClaudeCode) ? store.node.state : nil, @@ -264,15 +267,18 @@ struct LoopWorkspaceView: View { } /// An unattended loop's agent tab is labelled for what it's actually doing rather than - /// "Claude Code" — a time-based session is running its own `/loop`, a goal-based one is - /// working toward a stop condition, and that distinction is the one thing a glance at - /// the tab strip should tell you apart from a turn-based loop's session. - private func agentTabTitle(for tab: TabLayout) -> String { - guard tab.primary.launchesClaudeCode else { return "Shell" } - switch store.node.loopType { + /// for the CLI doing it — a time-based session is running its own `/loop`, a goal-based + /// one is working toward a stop condition, and that distinction is the one thing a + /// glance at the tab strip should tell you apart from an attended loop's session. Every + /// other type names its backend, which is only Claude Code when the loop chose it (#255). + static func agentTabTitle(loopType: LoopType, backend: CLISessionBackendKind, launchesAgent: Bool) + -> String + { + guard launchesAgent else { return "Shell" } + switch loopType { case .timeBased: return "Loop" case .goalBased: return "Goal" - case .sketch, .turnBased, .composite: return "Claude Code" + case .sketch, .turnBased, .composite: return backend.displayName } } diff --git a/graphcode/Tests/AgentTabTitleTests.swift b/graphcode/Tests/AgentTabTitleTests.swift new file mode 100644 index 0000000..e15a53d --- /dev/null +++ b/graphcode/Tests/AgentTabTitleTests.swift @@ -0,0 +1,43 @@ +import GraphcodeKit +import Testing + +@testable import graphcode + +/// What the agent tab in a loop's tab strip is called. +/// +/// A rule rather than a view: the strip is what tells you at a glance whether a session +/// is running its own cadence, working to a stop condition, or just sitting at a prompt — +/// and, for that last case, which CLI is sitting there. It named Claude Code whatever the +/// loop had actually chosen (#255), which is exactly the kind of thing no pixel test +/// would have caught. +@Suite +struct AgentTabTitleTests { + private func title( + _ loopType: LoopType, _ backend: CLISessionBackendKind = .claudeCode, agent: Bool = true + ) -> String { + LoopWorkspaceView.agentTabTitle(loopType: loopType, backend: backend, launchesAgent: agent) + } + + @Test + func anAttendedLoopNamesItsBackend() { + #expect(title(.sketch, .copilotCLI) == "Copilot CLI") + #expect(title(.turnBased, .codex) == "Codex") + #expect(title(.composite, .openCode) == "OpenCode") + #expect(title(.sketch, .claudeCode) == "Claude Code") + } + + @Test + func anUnattendedLoopNamesWhatItIsDoingInstead() { + for backend in CLISessionBackendKind.allCases { + #expect(title(.timeBased, backend) == "Loop") + #expect(title(.goalBased, backend) == "Goal") + } + } + + @Test + func aPlainShellPaneIsNeverNamedForABackend() { + for loopType in LoopType.allCases { + #expect(title(loopType, .copilotCLI, agent: false) == "Shell") + } + } +}