diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/AgentTurnStates.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/AgentTurnStates.swift index a08c8d4bc697..d05963b25a15 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/AgentTurnStates.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/AgentTurnStates.swift @@ -135,6 +135,14 @@ public nonisolated struct AgentTurnStates: Hashable, Sendable { !generated(from: summary["harness"] as? String), !generated(from: summary["family"] as? String) { return name } + if !includingPrompt { + // acpmux publishes the first prompt as `title` before its title + // request completes. Only a source that names a real chat title + // may feed durable workspace auto-naming; an absent source is an + // older daemon and is intentionally treated as unknown. + let source = summary["titleSource"] as? String + guard let source, ["harness", "generated", "adopted"].contains(source) else { return nil } + } if let title = summary["title"] as? String, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return promptGist(title) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextWorkspaceNaming/WorkspaceAutoNameTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextWorkspaceNaming/WorkspaceAutoNameTunables.swift index d1285d8b7be6..01ffba33d6e5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextWorkspaceNaming/WorkspaceAutoNameTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextWorkspaceNaming/WorkspaceAutoNameTunables.swift @@ -1,15 +1,14 @@ public import CmuxNextDesignTokens -/// The workspace auto-name prototypes (hq#1957; Debug Settings -/// `workspaces.autoName.variant`, DEV and NIGHTLY only; Release always uses -/// `live`). One is deleted once Leo picks after dogfood. +/// The workspace auto-name variants (hq#1957; Debug Settings +/// `workspaces.autoName.variant`, DEV and NIGHTLY only). Persisted first-chat +/// titles are the default; live labels remain available for comparison. public nonisolated enum WorkspaceAutoNameVariant: String, Sendable, CaseIterable, TunableChoice { - /// A: the sidebar row shows the name its front tab gives, and follows that - /// tab as it changes; the daemon keeps the default name. + /// The sidebar row shows the name its front tab gives, and follows that tab + /// as it changes; the daemon keeps the default name. case live - /// B: as `live`, and the first name the front tab gives is written to the - /// daemon once (Rename Workspace's command), so the CLI, window title and - /// search see it. + /// The first generated chat title is written to the daemon once, so the + /// CLI, window title and search see it. Manual and agent renames win. case persist public var tunableTitle: String { @@ -27,7 +26,7 @@ public nonisolated struct WorkspaceAutoNameTunables { public static let variant = Tunable.choice( "workspaces.autoName.variant", .sidebar, "Workspace auto-name", help: "How a workspace you never named takes its front tab's name. Applies at once.", - default: .live, code: "WorkspaceAutoNameTunables.variant") + default: .persist, code: "WorkspaceAutoNameTunables.variant") public static var all: [TunableDescriptor] { [variant.descriptor] } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentTabNamingTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentTabNamingTests.swift index 9beede9bf753..0b03b70f4b50 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentTabNamingTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentTabNamingTests.swift @@ -49,6 +49,22 @@ struct AgentTabNamingTests { "a name the user gave still wins") } + @Test func generatedTitleDoesNotTreatPromptFallbackAsGenerated() { + let prompt = [ + "sessionId": "p1", "name": "claude-1", "harness": "claude", + "title": "Fix the sidebar drag", "titleSource": "prompt", + ] as [String: Any] + #expect(AgentTurnStates.title(prompt, includingPrompt: false) == nil) + + var generated = prompt + generated["titleSource"] = "generated" + #expect(AgentTurnStates.title(generated, includingPrompt: false) == "Fix the sidebar drag") + + var renamed = prompt + renamed["titleSource"] = "named" + #expect(AgentTurnStates.title(renamed, includingPrompt: false) == nil) + } + @Test func anUntitledChatReadsAsItsWorkspacesTask() { let turns = AgentTurnStateStore() let ref = AgentSessionRef(host: Self.chiefHost, session: "c9", harness: "claude") diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextWorkspaceNamingTests/WorkspaceAutoNameTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextWorkspaceNamingTests/WorkspaceAutoNameTests.swift index 0f207f92a3f4..caaf2822f692 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextWorkspaceNamingTests/WorkspaceAutoNameTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextWorkspaceNamingTests/WorkspaceAutoNameTests.swift @@ -9,6 +9,10 @@ struct WorkspaceAutoNameTests { private static let live = WorkspaceAutoName(enabled: true, variant: .live) private static let persist = WorkspaceAutoName(enabled: true, variant: .persist) + @Test func theDefaultVariantPersistsTheFirstChatTitle() { + #expect(WorkspaceAutoNameTunables.variant.defaultValue == .persist) + } + private static func terminal(title: String? = nil, cwd: String? = nil, userName: String? = nil) -> WorkspaceAutoName.Front { .terminal(NewWorkspaceName.Tab(kind: .terminal, userName: userName, title: title, pageTitle: nil, url: nil, cwd: cwd)) } diff --git a/cmux-tui/crates/acpmux/spec/acpmux-protocol.json b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json index dea3d4f1d1a5..572faa5b5fbe 100644 --- a/cmux-tui/crates/acpmux/spec/acpmux-protocol.json +++ b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json @@ -3372,6 +3372,13 @@ "null" ] }, + "titleSource": { + "description": "Who supplied `title`: `prompt` is the first-prompt fallback, while\n`harness`, `generated` and `adopted` are real chat titles. Older peers\nmay omit this field.", + "type": [ + "string", + "null" + ] + }, "tools": { "description": "The chat's tools (`hub/tools.rs`).", "items": { diff --git a/cmux-tui/crates/acpmux/src/hub/views.rs b/cmux-tui/crates/acpmux/src/hub/views.rs index 05c6330b2551..bd2712129993 100644 --- a/cmux-tui/crates/acpmux/src/hub/views.rs +++ b/cmux-tui/crates/acpmux/src/hub/views.rs @@ -289,6 +289,7 @@ impl Hub { depth: m.subsession.as_ref().map_or(0, |l| l.depth), children: self.subsessions.open_count(&m.id), title: m.title.clone(), + title_source: m.title_source.clone(), last_prompt: m.last_prompt.clone(), preview: m.preview.as_deref().map(|p| short_text(p, 160)), last_message: last_message(&m), diff --git a/cmux-tui/crates/acpmux/src/protocol/session.rs b/cmux-tui/crates/acpmux/src/protocol/session.rs index 93a51afe64dc..8fe4ac88aff8 100644 --- a/cmux-tui/crates/acpmux/src/protocol/session.rs +++ b/cmux-tui/crates/acpmux/src/protocol/session.rs @@ -53,6 +53,12 @@ pub struct SessionSummary { /// Its sub-sessions that are not closed. pub children: u32, pub title: Option, + /// Who supplied `title`: `prompt` is the first-prompt fallback, while + /// `harness`, `generated` and `adopted` are real chat titles. Older peers + /// may omit this field. + #[serde(default)] + #[schemars(default)] + pub title_source: Option, pub last_prompt: Option, pub preview: Option, /// One sidebar line; null before the first prompt. diff --git a/webviews/src/agent-session/acpmux/protocol/generated/types.ts b/webviews/src/agent-session/acpmux/protocol/generated/types.ts index 1b99bc4eb7f8..4239830ed01f 100644 --- a/webviews/src/agent-session/acpmux/protocol/generated/types.ts +++ b/webviews/src/agent-session/acpmux/protocol/generated/types.ts @@ -701,6 +701,12 @@ export type SessionSummary = { /** Tags that have not expired. */ tags: Record; title?: (string | null); + /** + * Who supplied `title`: `prompt` is the first-prompt fallback, while + * `harness`, `generated` and `adopted` are real chat titles. Older peers + * may omit this field. + */ + titleSource?: (string | null); /** The chat's tools (`hub/tools.rs`). */ tools: ToolSummary[]; /** `_acpmux/set_tools` can change them. */