Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}

Expand Down
43 changes: 43 additions & 0 deletions graphcode/Tests/AgentTabTitleTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Loading