diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index aa6719458..3882c5639 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -661,10 +661,12 @@ import { AUTO_LANE_IDENTITY_JSON_SCHEMA, AUTO_TITLE_SYSTEM_PROMPT, buildSessionIntelligenceModelCandidates, + formatConversationTranscript, LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT, LEGACY_LANE_NAME_SYSTEM_PROMPT, MAX_NAMING_WORDS, runNamingAcrossProviders, + type SessionMetadataConversationEntry, } from "./sessionNaming"; import { createSessionMetadataRegenerator } from "./sessionMetadataService"; import { @@ -9462,25 +9464,6 @@ export function createAgentChatService(args: { `${label} timed out after ${timeoutMs}ms`, ); - const readTranscriptConversationEntries = (managed: ManagedChatSession): string[] => { - try { - return readTranscriptEnvelopes(managed) - .flatMap((entry) => { - if (entry.event.type === "user_message") { - const text = entry.event.text.trim(); - return text.length ? [`User: ${text}`] : []; - } - if (entry.event.type === "text") { - const text = entry.event.text.trim(); - return text.length ? [`Assistant: ${text}`] : []; - } - return []; - }); - } catch { - return []; - } - }; - const readTranscriptEntries = async ( managed: ManagedChatSession, signal?: AbortSignal, @@ -11103,17 +11086,40 @@ export function createAgentChatService(args: { }); }; - const buildRecentConversationContext = (managed: ManagedChatSession, limit = 20): string => { - const liveEntries = managed.recentConversationEntries.map((entry) => - `${entry.role === "user" ? "User" : "Assistant"}: ${entry.text}`, - ); - const combined: string[] = []; - for (const entry of [...readTranscriptConversationEntries(managed), ...liveEntries]) { - if (!entry.trim().length) continue; - if (combined[combined.length - 1] === entry) continue; + const collectConversationEntries = ( + managed: ManagedChatSession, + ): SessionMetadataConversationEntry[] => { + const fromTranscript: SessionMetadataConversationEntry[] = []; + try { + for (const entry of readTranscriptEnvelopes(managed)) { + if (entry.event.type === "user_message") { + const text = entry.event.text.trim(); + if (text) fromTranscript.push({ role: "user", text }); + } else if (entry.event.type === "text") { + const text = entry.event.text.trim(); + if (text) fromTranscript.push({ role: "assistant", text }); + } + } + } catch { + // Live ring still helps when the transcript file is unreadable. + } + const live = managed.recentConversationEntries + .map((entry) => ({ + role: entry.role, + text: entry.text.trim(), + })) + .filter((entry) => entry.text.length > 0); + const combined: SessionMetadataConversationEntry[] = []; + for (const entry of [...fromTranscript, ...live]) { + const prev = combined[combined.length - 1]; + if (prev && prev.role === entry.role && prev.text === entry.text) continue; combined.push(entry); } - return combined.slice(-limit).join("\n"); + return combined; + }; + + const buildRecentConversationContext = (managed: ManagedChatSession, limit = 20): string => { + return formatConversationTranscript(collectConversationEntries(managed).slice(-limit)); }; const usesIdentityContinuity = (managed: ManagedChatSession): boolean => Boolean(managed.session.identityKey); @@ -12050,8 +12056,33 @@ export function createAgentChatService(args: { sessionModel: managed.session.model, }); }, - buildRecentConversationContext: (managed, limit) => - buildRecentConversationContext(managed, limit), + collectConversationEntries: (managed) => collectConversationEntries(managed), + listLaneThreads: (managed) => { + const rows = sessionService.list({ laneId: managed.session.laneId, limit: 40 }); + return rows + .filter((row) => isChatToolType(row.toolType)) + .map((row) => ({ + title: row.title, + statusNote: row.statusNote, + summary: row.summary, + isCurrent: row.id === managed.session.id, + })); + }, + gatherLaneWorkVersusRemote: async ({ worktreePath, baseRef }) => { + const cwd = worktreePath.trim(); + if (!cwd) return null; + const [changedFiles, commits, uncommitted] = await Promise.all([ + runGit(["diff", "--name-status", `${baseRef}...HEAD`], { cwd, timeoutMs: 8_000 }).catch(() => null), + runGit(["log", "-n20", "--oneline", `${baseRef}..HEAD`], { cwd, timeoutMs: 8_000 }).catch(() => null), + runGit(["status", "--short"], { cwd, timeoutMs: 8_000 }).catch(() => null), + ]); + return { + baseRef, + changedFiles: changedFiles?.exitCode === 0 ? changedFiles.stdout : null, + commits: commits?.exitCode === 0 ? commits.stdout : null, + uncommitted: uncommitted?.exitCode === 0 ? uncommitted.stdout : null, + }; + }, runPrompt: ({ cwd, modelId, prompt, systemPrompt, jsonSchema }) => runSessionIntelligencePrompt({ cwd, modelId, diff --git a/apps/desktop/src/main/services/chat/sessionMetadataService.test.ts b/apps/desktop/src/main/services/chat/sessionMetadataService.test.ts index 42231c559..1dca05385 100644 --- a/apps/desktop/src/main/services/chat/sessionMetadataService.test.ts +++ b/apps/desktop/src/main/services/chat/sessionMetadataService.test.ts @@ -4,7 +4,7 @@ import type { AgentChatSession } from "../../../shared/types/chat"; import { getAvailableModels } from "../../../shared/modelRegistry"; import type { Logger } from "../logging/logger"; import { createSessionMetadataRegenerator, type SessionMetadataManagedSession } from "./sessionMetadataService"; -import type { SessionMetadataPromptRunner } from "./sessionNaming"; +import type { SessionMetadataConversationEntry, SessionMetadataLaneThread, SessionMetadataPromptRunner } from "./sessionNaming"; const ANTHROPIC_MODELS = getAvailableModels([ { type: "cli-subscription", cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, @@ -25,6 +25,14 @@ function createHarness(args?: { autoTitleSeed?: string | null; preview?: string | null; resolveModelCandidates?: () => Promise; + conversation?: SessionMetadataConversationEntry[]; + laneThreads?: SessionMetadataLaneThread[]; + laneWork?: { + baseRef: string; + commits?: string | null; + changedFiles?: string | null; + uncommitted?: string | null; + } | null; }) { const managed = { session: { @@ -55,6 +63,9 @@ function createHarness(args?: { return true; }); const renameLane = vi.fn(); + const collectConversationEntries = vi.fn(() => args?.conversation ?? []); + const listLaneThreads = vi.fn(() => args?.laneThreads ?? []); + const gatherLaneWorkVersusRemote = vi.fn(async () => args?.laneWork ?? null); const runPrompt = vi.fn, ReturnType>( args?.runPrompt ?? (async () => ({ text: JSON.stringify({ @@ -68,9 +79,11 @@ function createHarness(args?: { const regenerate = createSessionMetadataRegenerator({ ensureManagedSession: () => managed, getSession: () => sessionRow, - getLaneSummary: async () => ({ name: sessionRow.laneName }), + getLaneSummary: async () => ({ name: sessionRow.laneName, worktreePath: managed.laneWorktreePath }), resolveModelCandidates: args?.resolveModelCandidates ?? (async () => [ANTHROPIC_MODELS[0]!.id]), - buildRecentConversationContext: () => "", + collectConversationEntries, + listLaneThreads, + gatherLaneWorkVersusRemote, runPrompt, normalizeTitle, normalizeStatusLine, @@ -80,7 +93,18 @@ function createHarness(args?: { persistChatState: vi.fn(), logger, }); - return { regenerate, managed, sessionRow, applyTitle, setStatusNote, renameLane, runPrompt }; + return { + regenerate, + managed, + sessionRow, + applyTitle, + setStatusNote, + renameLane, + runPrompt, + collectConversationEntries, + listLaneThreads, + gatherLaneWorkVersusRemote, + }; } describe("createSessionMetadataRegenerator", () => { @@ -144,4 +168,158 @@ describe("createSessionMetadataRegenerator", () => { expect(setStatusNote).toHaveBeenCalled(); expect(renameLane).toHaveBeenCalled(); }); + + it("sends the full thread, latest assistant paragraphs, lane threads, and git work in one call", async () => { + const { regenerate, runPrompt } = createHarness({ + conversation: [ + { role: "user", text: "stop one-shot AI from picking Haiku" }, + { role: "assistant", text: "Looked at executeTask.\n\nRemoved the default namer.\n\nTests are running on the skip path." }, + ], + laneThreads: [ + { title: "Stop Haiku default", statusNote: "Tests are running on the skip path.", isCurrent: true }, + { title: "Conflict picker", summary: "Added the settings row" }, + ], + laneWork: { + baseRef: "origin/main", + changedFiles: "M apps/desktop/src/main/services/ai/aiIntegrationService.ts", + commits: "c9685fa Stop one-shot AI from picking Haiku", + uncommitted: "", + }, + }); + + await regenerate({ sessionId: "sess-1" }); + expect(runPrompt).toHaveBeenCalledTimes(1); + const prompt = String(runPrompt.mock.calls[0]?.[0]?.prompt ?? ""); + expect(prompt).toContain("source for chatTitle"); + expect(prompt).toContain("stop one-shot AI from picking Haiku"); + expect(prompt).toContain("source for statusLine"); + expect(prompt).toContain("Tests are running on the skip path."); + expect(prompt).toContain("Conflict picker"); + expect(prompt).toContain("Work on this lane that differs from remote"); + expect(prompt).toContain("aiIntegrationService.ts"); + }); + + it("does not gather sibling threads or git work for a status-only refresh", async () => { + const { + regenerate, + runPrompt, + listLaneThreads, + gatherLaneWorkVersusRemote, + } = createHarness({ + conversation: [ + { role: "user", text: "rewrite every naming prompt" }, + { role: "assistant", text: "Looked at executeTask.\n\nRemoved the default namer.\n\nTests are running on the skip path." }, + ], + laneThreads: [{ title: "Conflict picker", isCurrent: false }], + laneWork: { + baseRef: "origin/main", + changedFiles: "M apps/desktop/src/main/services/ai/aiIntegrationService.ts", + }, + }); + + await regenerate({ sessionId: "sess-1", fields: ["statusLine"] }); + expect(listLaneThreads).not.toHaveBeenCalled(); + expect(gatherLaneWorkVersusRemote).not.toHaveBeenCalled(); + expect(runPrompt).toHaveBeenCalledTimes(1); + const call = runPrompt.mock.calls[0]?.[0]; + const prompt = String(call?.prompt ?? ""); + expect(prompt).toContain("long-running coding thread"); + expect(prompt).toContain("Users manage many threads"); + expect(prompt).toContain("Lane name: Start Skill Using Aws Other"); + expect(prompt).toContain("Worktree: lane"); + expect(prompt).toContain("Chat title: Start Skill Using Aws Other"); + expect(prompt).toContain("Tests are running on the skip path."); + expect(prompt).not.toContain("rewrite every naming prompt"); + expect(prompt).not.toContain("Conflict picker"); + expect(prompt).not.toContain("aiIntegrationService.ts"); + expect(String(call?.systemPrompt ?? "")).toContain( + "Copy these current values unchanged: chatTitle, laneName.", + ); + }); + + it("does not gather git work for a title-only refresh", async () => { + const { regenerate, runPrompt, listLaneThreads, gatherLaneWorkVersusRemote } = createHarness({ + conversation: [ + { role: "user", text: "stop one-shot AI from picking Haiku" }, + { role: "assistant", text: "Removed the default namer." }, + ], + laneThreads: [{ title: "Conflict picker" }], + laneWork: { + baseRef: "origin/main", + changedFiles: "M apps/desktop/src/main/services/ai/aiIntegrationService.ts", + }, + }); + + await regenerate({ sessionId: "sess-1", fields: ["title"] }); + expect(listLaneThreads).not.toHaveBeenCalled(); + expect(gatherLaneWorkVersusRemote).not.toHaveBeenCalled(); + const prompt = String(runPrompt.mock.calls[0]?.[0]?.prompt ?? ""); + expect(prompt).toContain("stop one-shot AI from picking Haiku"); + expect(prompt).not.toContain("aiIntegrationService.ts"); + expect(prompt).not.toContain("Conflict picker"); + }); + + it("does not collect the transcript when only the lane name is requested", async () => { + const { regenerate, collectConversationEntries, listLaneThreads, gatherLaneWorkVersusRemote } = createHarness({ + conversation: [{ role: "user", text: "rewrite every naming prompt" }], + laneThreads: [{ title: "Conflict picker" }], + laneWork: { + baseRef: "origin/main", + changedFiles: "M apps/desktop/src/auth.ts", + }, + }); + + await regenerate({ sessionId: "sess-1", fields: ["laneName"] }); + expect(collectConversationEntries).not.toHaveBeenCalled(); + expect(listLaneThreads).toHaveBeenCalled(); + expect(gatherLaneWorkVersusRemote).toHaveBeenCalled(); + }); + + it("titles from this thread when models fail, not from the kickoff slug", async () => { + const { regenerate, applyTitle, renameLane } = createHarness({ + resolveModelCandidates: async () => [], + conversation: [ + { role: "user", text: "stop one-shot AI from picking Haiku" }, + { role: "assistant", text: "Removed the default namer so skip-path tests stay green." }, + ], + }); + + const result = await regenerate({ sessionId: "sess-1", fields: ["title"] }); + expect(result.applied).toEqual(["title"]); + expect(renameLane).not.toHaveBeenCalled(); + expect(applyTitle).toHaveBeenCalled(); + expect(String(applyTitle.mock.calls[0]?.[1])).not.toMatch(/start skill using aws/i); + expect(String(applyTitle.mock.calls[0]?.[1])).toMatch(/stop one shot/i); + }); + + it("prefers this thread over the kickoff slug when generating all three without models", async () => { + const { regenerate, applyTitle, renameLane } = createHarness({ + resolveModelCandidates: async () => [], + conversation: [ + { role: "user", text: "stop one-shot AI from picking Haiku" }, + { role: "assistant", text: "Removed the default namer so skip-path tests stay green." }, + ], + }); + + const result = await regenerate({ sessionId: "sess-1" }); + expect(result.applied.length).toBeGreaterThan(0); + expect(applyTitle).toHaveBeenCalled(); + expect(String(applyTitle.mock.calls[0]?.[1])).not.toMatch(/start skill using aws/i); + expect(String(applyTitle.mock.calls[0]?.[1])).toMatch(/stop one shot/i); + expect(renameLane).toHaveBeenCalled(); + expect(String(renameLane.mock.calls[0]?.[0]?.name)).not.toMatch(/start skill using aws/i); + }); + + it("does not stamp this thread's kickoff onto the shared lane when models fail", async () => { + const { regenerate, applyTitle, renameLane, setStatusNote } = createHarness({ + resolveModelCandidates: async () => [], + }); + + await expect(regenerate({ sessionId: "sess-1", fields: ["laneName"] })).rejects.toThrow( + "The AI returned no usable session metadata.", + ); + expect(renameLane).not.toHaveBeenCalled(); + expect(applyTitle).not.toHaveBeenCalled(); + expect(setStatusNote).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/main/services/chat/sessionMetadataService.ts b/apps/desktop/src/main/services/chat/sessionMetadataService.ts index a0383a61e..5c15bbe8c 100644 --- a/apps/desktop/src/main/services/chat/sessionMetadataService.ts +++ b/apps/desktop/src/main/services/chat/sessionMetadataService.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + import type { AgentChatRegenerateSessionMetadataArgs, AgentChatRegenerateSessionMetadataResult, @@ -5,10 +7,21 @@ import type { AgentChatSessionMetadataField, } from "../../../shared/types/chat"; import { normalizeAgentChatSessionMetadataFields } from "../../../shared/types/chat"; + import { buildSessionMetadataPrompt, + buildSessionMetadataSystemPrompt, + clipFromEnd, deriveDeterministicSessionMetadata, + extractLatestAssistantParagraphs, + formatConversationTranscript, + formatLaneThreadsForPrompt, + formatLaneWorkVersusRemote, runSessionMetadataGeneration, + sessionMetadataPromptNeeds, + SESSION_METADATA_TRANSCRIPT_CHAR_LIMIT, + type SessionMetadataConversationEntry, + type SessionMetadataLaneThread, type SessionMetadataPromptRunner, } from "./sessionNaming"; import type { Logger } from "../logging/logger"; @@ -33,6 +46,15 @@ export type SessionMetadataSessionRow = { export type SessionMetadataLaneSummary = { name: string; + baseRef?: string | null; + worktreePath?: string | null; +}; + +export type SessionMetadataLaneWorkSnapshot = { + baseRef: string; + commits?: string | null; + changedFiles?: string | null; + uncommitted?: string | null; }; export type SessionMetadataRegeneratorDependencies = { @@ -43,7 +65,12 @@ export type SessionMetadataRegeneratorDependencies Promise; resolveModelCandidates: (managed: ManagedSession) => Promise; - buildRecentConversationContext: (managed: ManagedSession, limit: number) => string; + collectConversationEntries: (managed: ManagedSession) => SessionMetadataConversationEntry[]; + listLaneThreads: (managed: ManagedSession) => SessionMetadataLaneThread[]; + gatherLaneWorkVersusRemote: (args: { + worktreePath: string; + baseRef: string; + }) => Promise; runPrompt: SessionMetadataPromptRunner; normalizeTitle: (value: string) => string | null; normalizeStatusLine: (value: string) => string | null; @@ -110,17 +137,47 @@ export function createSessionMetadataRegenerator null) + : null; + const laneWorkVersusRemote = laneWorkSnapshot + ? formatLaneWorkVersusRemote(laneWorkSnapshot) + : ""; + const worktreeName = path.basename( + initialLane?.worktreePath || managed.laneWorktreePath, + ) || null; let generated: { result: ReturnType; @@ -134,16 +191,22 @@ export function createSessionMetadataRegenerator { }); }); }); + +describe("session metadata context helpers", () => { + it("clips from the end so the latest work survives the prompt cap", () => { + expect(clipFromEnd("abcdefghij", 4)).toBe("…(earlier omitted)\nghij"); + expect(clipFromEnd("short", 40)).toBe("short"); + }); + + it("takes the last two or three assistant paragraphs for the status line", () => { + expect(extractLatestAssistantParagraphs([ + { role: "user", text: "fix login" }, + { role: "assistant", text: "First look.\n\nOpened the auth store.\n\nWired the fallback and the tests are running." }, + ])).toBe("First look.\n\nOpened the auth store.\n\nWired the fallback and the tests are running."); + expect(extractLatestAssistantParagraphs([ + { role: "assistant", text: "Intro.\n\nHunk one.\n\nHunk two.\n\nCurrently rebasing onto main." }, + ], 3)).toBe("Hunk one.\n\nHunk two.\n\nCurrently rebasing onto main."); + }); + + it("labels lane threads and git work as the sources for each field", () => { + const prompt = buildSessionMetadataPrompt({ + provider: "cursor", + chatModel: "grok-4.6", + currentLaneName: "Old lane", + currentChatTitle: "Old title", + requestedFields: ["title", "laneName", "statusLine"], + threadTranscript: "User: fix login\nAssistant: Wired the fallback.", + latestAssistantParagraphs: "Wired the fallback.", + laneThreads: "- Fix login (this thread)\n- Review auth tests", + laneWorkVersusRemote: "Compared to origin/main:\nChanged files:\nM apps/desktop/src/auth.ts", + }); + expect(prompt).toContain("source for chatTitle"); + expect(prompt).toContain("User: fix login"); + expect(prompt).toContain("source for statusLine"); + expect(prompt).toContain("Wired the fallback."); + expect(prompt).toContain("source for laneName, together with git work"); + expect(prompt).toContain("Review auth tests"); + expect(prompt).toContain("Work on this lane that differs from remote"); + expect(prompt).toContain("apps/desktop/src/auth.ts"); + }); + + it("sends a lean status-only prompt even when a full transcript and git dump are passed", () => { + const prompt = buildSessionMetadataPrompt({ + provider: "cursor", + chatModel: "grok-4.6", + currentLaneName: "Auth fallback", + currentChatTitle: "Desktop auth fallback", + currentStatusLine: "Opened the auth store", + worktreeName: "start-ctonext-skill-session-lane", + requestedFields: ["statusLine"], + goal: "should not appear", + summary: "should not appear either", + originalRequest: "start skill using aws other", + threadTranscript: "User: rewrite every naming prompt\nAssistant: Looked at executeTask first.", + latestAssistantParagraphs: "Wired the fallback and the tests are running.", + laneThreads: "- Fix login (this thread)\n- Review auth tests", + laneWorkVersusRemote: "Compared to origin/main:\nChanged files:\nM apps/desktop/src/auth.ts", + }); + expect(prompt).toContain("long-running coding thread"); + expect(prompt).toContain("Users manage many threads"); + expect(prompt).toContain("Lane name: Auth fallback"); + expect(prompt).toContain("Worktree: start-ctonext-skill-session-lane"); + expect(prompt).toContain("Chat title: Desktop auth fallback"); + expect(prompt).toContain("Wired the fallback and the tests are running."); + expect(prompt).toContain("Repeat the current chatTitle and laneName unchanged"); + expect(prompt).not.toContain("rewrite every naming prompt"); + expect(prompt).not.toContain("Review auth tests"); + expect(prompt).not.toContain("apps/desktop/src/auth.ts"); + expect(prompt).not.toContain("start skill using aws other"); + expect(prompt).not.toContain("source for chatTitle"); + }); + + it("sends this thread's transcript for a title-only refresh and omits git work", () => { + const prompt = buildSessionMetadataPrompt({ + provider: "cursor", + chatModel: "grok-4.6", + currentLaneName: "Auth fallback", + currentChatTitle: "Old title", + requestedFields: ["title"], + threadTranscript: "User: fix login\nAssistant: Wired the fallback.", + latestAssistantParagraphs: "Wired the fallback.", + laneThreads: "- Review auth tests", + laneWorkVersusRemote: "Compared to origin/main:\nChanged files:\nM apps/desktop/src/auth.ts", + }); + expect(prompt).toContain("source for chatTitle"); + expect(prompt).toContain("User: fix login"); + expect(prompt).toContain("Repeat these current values unchanged: laneName, statusLine."); + expect(prompt).not.toContain("source for statusLine"); + expect(prompt).not.toContain("Review auth tests"); + expect(prompt).not.toContain("differs from remote"); + }); +}); + +describe("buildSessionMetadataSystemPrompt", () => { + it("keeps the all-three namer instructions when every field is requested", () => { + expect(buildSessionMetadataSystemPrompt(["title", "laneName", "statusLine"])) + .toBe(SESSION_METADATA_SYSTEM_PROMPT); + expect(buildSessionMetadataSystemPrompt()).toBe(SESSION_METADATA_SYSTEM_PROMPT); + }); + + it("tells a status-only namer to copy the current title and lane name", () => { + const systemPrompt = buildSessionMetadataSystemPrompt(["statusLine"]); + expect(systemPrompt).toContain("Users scan many threads at once"); + expect(systemPrompt).toContain("Write new values for: statusLine."); + expect(systemPrompt).toContain("Copy these current values unchanged: chatTitle, laneName."); + expect(systemPrompt).toContain("Derive this only from the latest assistant output"); + expect(systemPrompt).not.toContain("every thread in this lane"); + expect(systemPrompt).not.toContain("full conversation transcript"); + }); +}); diff --git a/apps/desktop/src/main/services/chat/sessionNaming.ts b/apps/desktop/src/main/services/chat/sessionNaming.ts index f721a2e0f..54181b3d8 100644 --- a/apps/desktop/src/main/services/chat/sessionNaming.ts +++ b/apps/desktop/src/main/services/chat/sessionNaming.ts @@ -18,6 +18,7 @@ import { type ModelDescriptor, type ModelProviderGroup, } from "../../../shared/modelRegistry"; +import type { AgentChatSessionMetadataField } from "../../../shared/types/chat"; import { parseStructuredOutput } from "../ai/utils"; /** @@ -36,22 +37,94 @@ Return only the title text. - No emoji. - No trailing punctuation.`; -export const SESSION_METADATA_SYSTEM_PROMPT = `You name the visible metadata for a software development chat in ADE. -Return strict JSON only with exactly these string fields: {"chatTitle":"...","laneName":"...","statusLine":"..."}. -chatTitle: +const SESSION_METADATA_JSON_INSTRUCTION = + `Return strict JSON only with exactly these string fields: {"chatTitle":"...","laneName":"...","statusLine":"..."}.`; + +const SESSION_METADATA_TITLE_RULES = `chatTitle — this thread only: +- Name the work done in THIS chat thread. The full conversation transcript is the source of truth. - A meaningful 2 to ${MAX_NAMING_WORDS} word title for the task, feature, bug, or deliverable. - Do not start with Completed, Complete, Done, Finished, Resolved, or Success. - Do not use generic words such as Chat, Session, Status, or Untitled by themselves. -- No quotes, emoji, or trailing punctuation. -laneName: -- A readable 2 to ${MAX_NAMING_WORDS} word name for the durable workstream. +- No quotes, emoji, or trailing punctuation.`; + +const SESSION_METADATA_LANE_RULES = `laneName — the durable workstream for the whole lane: +- Combine every thread in this lane with the git work that differs from the remote/base. - Describe the feature, bug, UI surface, or outcome rather than the act of asking. -- No branch prefixes, slash characters, quotes, emoji, or trailing punctuation. -statusLine: +- A readable 2 to ${MAX_NAMING_WORDS} word name. No branch prefixes, slash characters, quotes, emoji, or trailing punctuation.`; + +const SESSION_METADATA_STATUS_RULES = `statusLine — what is currently being done: +- Derive this only from the latest assistant output (the last two or three paragraphs of what the agent just said or did). - A concise current progress or outcome line, at most 72 characters and ideally ${MAX_NAMING_WORDS} words or fewer. -- State only what the supplied context supports. Never invent a completion, blocker, test result, or decision. -- No quotes, emoji, or trailing punctuation. -Use the current metadata only as context; the user's explicit regenerate choice permits replacing it.`; +- State only what that latest output supports. Never invent a completion, blocker, test result, or decision. +- No quotes, emoji, or trailing punctuation.`; + +export const SESSION_METADATA_SYSTEM_PROMPT = `You name the visible metadata for a software development chat in ADE. +${SESSION_METADATA_JSON_INSTRUCTION} +Always fill all three fields. Current metadata is context only; the user's explicit regenerate choice permits replacing it. + +${SESSION_METADATA_TITLE_RULES} + +${SESSION_METADATA_LANE_RULES} + +${SESSION_METADATA_STATUS_RULES}`; + +export type SessionMetadataPromptNeeds = { + title: boolean; + laneName: boolean; + statusLine: boolean; +}; + +/** Which prompt sources to gather and send for this regenerate request. */ +export function sessionMetadataPromptNeeds( + fields?: readonly AgentChatSessionMetadataField[] | null, +): SessionMetadataPromptNeeds { + const requested = fields ?? []; + if (!requested.length) { + return { title: true, laneName: true, statusLine: true }; + } + return { + title: requested.includes("title"), + laneName: requested.includes("laneName"), + statusLine: requested.includes("statusLine"), + }; +} + +export function buildSessionMetadataSystemPrompt( + fields?: readonly AgentChatSessionMetadataField[] | null, +): string { + const needs = sessionMetadataPromptNeeds(fields); + if (needs.title && needs.laneName && needs.statusLine) { + return SESSION_METADATA_SYSTEM_PROMPT; + } + + const write: string[] = []; + const copy: string[] = []; + if (needs.title) write.push("chatTitle"); + else copy.push("chatTitle"); + if (needs.laneName) write.push("laneName"); + else copy.push("laneName"); + if (needs.statusLine) write.push("statusLine"); + else copy.push("statusLine"); + + const intro = needs.statusLine && !needs.title && !needs.laneName + ? [ + "You write a short status line for a software development chat in ADE.", + "Users scan many threads at once and need to see what this agent just did.", + ].join("\n") + : "You name the visible metadata for a software development chat in ADE."; + + return [ + intro, + SESSION_METADATA_JSON_INSTRUCTION, + [ + write.length ? `Write new values for: ${write.join(", ")}.` : null, + copy.length ? `Copy these current values unchanged: ${copy.join(", ")}.` : null, + ].filter(Boolean).join(" "), + needs.title ? SESSION_METADATA_TITLE_RULES : null, + needs.laneName ? SESSION_METADATA_LANE_RULES : null, + needs.statusLine ? SESSION_METADATA_STATUS_RULES : null, + ].filter((line): line is string => Boolean(line)).join("\n\n"); +} export const LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT = `Generate the stable identity for an automatically created software workspace. Return strict JSON only: {"laneTitle":"...","branchFragment":"..."}. @@ -166,31 +239,169 @@ export function deriveDeterministicSessionMetadata(args: { return { chatTitle, laneName: chatTitle, statusLine }; } +export const SESSION_METADATA_TRANSCRIPT_CHAR_LIMIT = 64_000; +export const SESSION_METADATA_ASSISTANT_TAIL_CHAR_LIMIT = 6_000; +export const SESSION_METADATA_LANE_WORK_CHAR_LIMIT = 8_000; + +export type SessionMetadataConversationEntry = { + role: "user" | "assistant"; + text: string; +}; + +export type SessionMetadataLaneThread = { + title: string; + statusNote?: string | null; + summary?: string | null; + isCurrent?: boolean; +}; + +/** Keep the newest tail of a large blob so latest work survives the prompt cap. */ +export function clipFromEnd(text: string, maxChars: number): string { + const trimmed = text.trim(); + if (!trimmed) return ""; + if (trimmed.length <= maxChars) return trimmed; + return `…(earlier omitted)\n${trimmed.slice(trimmed.length - maxChars)}`; +} + +export function formatConversationTranscript( + entries: SessionMetadataConversationEntry[], +): string { + return entries + .filter((entry) => entry.text.trim()) + .map((entry) => `${entry.role === "user" ? "User" : "Assistant"}: ${entry.text.trim()}`) + .join("\n"); +} + +/** + * Status lines should come from the last two or three paragraphs of the + * agent's most recent output — what is currently being done — not from the + * kickoff prompt or a sibling thread. + */ +export function takeLastParagraphs(text: string, count = 3): string { + const trimmed = text.trim(); + if (!trimmed) return ""; + const paragraphs = trimmed.split(/\n\s*\n/u).map((part) => part.trim()).filter(Boolean); + if (paragraphs.length >= 2) { + return clipFromEnd(paragraphs.slice(-count).join("\n\n"), SESSION_METADATA_ASSISTANT_TAIL_CHAR_LIMIT); + } + const lines = trimmed.split(/\n/u).map((line) => line.trim()).filter(Boolean); + if (lines.length >= 2) { + return clipFromEnd(lines.slice(-Math.max(count, 3)).join("\n"), SESSION_METADATA_ASSISTANT_TAIL_CHAR_LIMIT); + } + return clipFromEnd(trimmed, SESSION_METADATA_ASSISTANT_TAIL_CHAR_LIMIT); +} + +export function extractLatestAssistantParagraphs( + entries: SessionMetadataConversationEntry[], + paragraphCount = 3, +): string { + const lastAssistant = [...entries].reverse().find((entry) => entry.role === "assistant" && entry.text.trim()); + return lastAssistant ? takeLastParagraphs(lastAssistant.text, paragraphCount) : ""; +} + +export function formatLaneThreadsForPrompt(threads: SessionMetadataLaneThread[]): string { + if (!threads.length) return ""; + return threads.map((thread) => { + const tag = thread.isCurrent ? " (this thread)" : ""; + const lines = [`- ${thread.title.trim() || "Untitled"}${tag}`]; + const status = thread.statusNote?.trim(); + const summary = thread.summary?.trim(); + if (status) lines.push(` status: ${status}`); + if (summary) lines.push(` summary: ${clipFromEnd(summary, 240)}`); + return lines.join("\n"); + }).join("\n"); +} + +export function formatLaneWorkVersusRemote(args: { + baseRef: string; + commits?: string | null; + changedFiles?: string | null; + uncommitted?: string | null; +}): string { + const commits = args.commits?.trim() || ""; + const changedFiles = args.changedFiles?.trim() || ""; + const uncommitted = args.uncommitted?.trim() || ""; + if (!commits && !changedFiles && !uncommitted) return ""; + return clipFromEnd( + [ + `Compared to ${args.baseRef}:`, + commits ? `Commits:\n${commits}` : null, + changedFiles ? `Changed files:\n${changedFiles}` : null, + uncommitted ? `Uncommitted:\n${uncommitted}` : null, + ].filter((line): line is string => Boolean(line)).join("\n\n"), + SESSION_METADATA_LANE_WORK_CHAR_LIMIT, + ); +} + export function buildSessionMetadataPrompt(args: { provider: string; chatModel?: string | null; currentLaneName?: string | null; currentChatTitle?: string | null; currentStatusLine?: string | null; + worktreeName?: string | null; + requestedFields?: readonly AgentChatSessionMetadataField[] | null; goal?: string | null; summary?: string | null; latestOutputPreview?: string | null; originalRequest?: string | null; - recentConversation?: string | null; + threadTranscript?: string | null; + latestAssistantParagraphs?: string | null; + laneThreads?: string | null; + laneWorkVersusRemote?: string | null; }): string { + const needs = sessionMetadataPromptNeeds(args.requestedFields); + const requested = args.requestedFields ?? []; + const statusSource = args.latestAssistantParagraphs?.trim() || args.latestOutputPreview?.trim() || ""; + + if (needs.statusLine && !needs.title && !needs.laneName) { + const recent = statusSource; + return [ + "This is a long-running coding thread in ADE.", + "Users manage many threads at once and need a short status line for what this agent has just done.", + args.currentLaneName?.trim() ? `Lane name: ${args.currentLaneName.trim()}` : null, + args.worktreeName?.trim() ? `Worktree: ${args.worktreeName.trim()}` : null, + args.currentChatTitle?.trim() ? `Chat title: ${args.currentChatTitle.trim()}` : null, + args.currentStatusLine?.trim() ? `Current status line: ${args.currentStatusLine.trim()}` : null, + recent + ? `Latest assistant output (what the agent has done in the last couple of minutes):\n${recent}` + : null, + "Write a short statusLine from that recent output only. Repeat the current chatTitle and laneName unchanged.", + ].filter((line): line is string => Boolean(line && line.trim().length)).join("\n\n"); + } + + const copyFields: string[] = []; + if (!needs.title) copyFields.push("chatTitle"); + if (!needs.laneName) copyFields.push("laneName"); + if (!needs.statusLine) copyFields.push("statusLine"); + return [ "The user explicitly asked ADE to refresh the selected session metadata.", - "Use the supplied context to produce all three fields, even when only some fields will be applied.", + "Produce all three JSON fields in one response, even when only some fields will be applied.", + requested.length ? `Fields the user asked to apply: ${requested.join(", ")}` : null, + copyFields.length + ? `Repeat these current values unchanged: ${copyFields.join(", ")}.` + : null, `Provider: ${args.provider}`, `Chat model: ${args.chatModel ?? ""}`, `Current lane name: ${args.currentLaneName ?? ""}`, `Current chat title: ${args.currentChatTitle ?? ""}`, args.currentStatusLine ? `Current status line: ${args.currentStatusLine}` : null, - args.goal ? `Chat goal: ${args.goal}` : null, - args.summary ? `Existing summary: ${args.summary}` : null, - args.latestOutputPreview ? `Latest output preview: ${args.latestOutputPreview}` : null, - args.originalRequest ? `Original request: ${args.originalRequest}` : null, - args.recentConversation ? `Recent conversation:\n${args.recentConversation}` : null, + needs.title && args.goal ? `Chat goal: ${args.goal}` : null, + needs.title && args.summary ? `Existing summary: ${args.summary}` : null, + needs.title && args.originalRequest ? `Original request: ${args.originalRequest}` : null, + needs.title && args.threadTranscript + ? `This thread's full conversation (source for chatTitle):\n${args.threadTranscript}` + : null, + needs.statusLine && statusSource + ? `Latest assistant output (source for statusLine — last 2-3 paragraphs of what is currently being done):\n${statusSource}` + : null, + needs.laneName && args.laneThreads + ? `Other threads in this lane (source for laneName, together with git work):\n${args.laneThreads}` + : null, + needs.laneName && args.laneWorkVersusRemote + ? `Work on this lane that differs from remote (source for laneName):\n${args.laneWorkVersusRemote}` + : null, ].filter((line): line is string => Boolean(line && line.trim().length)).join("\n\n"); } @@ -198,6 +409,7 @@ export async function runSessionMetadataGeneration(args: { candidateModelIds: string[]; cwd: string; prompt: string; + systemPrompt?: string; runPrompt: SessionMetadataPromptRunner; normalizeTitle: (value: string) => string | null; normalizeStatusLine: (value: string) => string | null; @@ -207,6 +419,7 @@ export async function runSessionMetadataGeneration(args: { // Walk the caller's setting-then-session candidates only. Cursor Grok (and // other non-schema models) often return unusable JSON; the next candidate // still gets a turn. ADE already holds the transcript excerpt. + const systemPrompt = args.systemPrompt ?? SESSION_METADATA_SYSTEM_PROMPT; return runNamingAcrossProviders(args.candidateModelIds, { shouldStop: args.shouldStop, run: async (descriptor) => { @@ -214,7 +427,7 @@ export async function runSessionMetadataGeneration(args: { cwd: args.cwd, modelId: descriptor.id, prompt: args.prompt, - systemPrompt: SESSION_METADATA_SYSTEM_PROMPT, + systemPrompt, jsonSchema: SESSION_METADATA_JSON_SCHEMA, }); const parserArgs = { diff --git a/apps/desktop/src/renderer/components/terminals/LaneChip.tsx b/apps/desktop/src/renderer/components/terminals/LaneChip.tsx index 148e449c1..e94be59f9 100644 --- a/apps/desktop/src/renderer/components/terminals/LaneChip.tsx +++ b/apps/desktop/src/renderer/components/terminals/LaneChip.tsx @@ -1,6 +1,7 @@ import type { ButtonHTMLAttributes, HTMLAttributes, MouseEvent } from "react"; import { cn } from "../ui/cn"; import { LaneIcon, BranchIcon } from "../ui/vcsIcons"; +import { LaneNamingLabel } from "./LaneNamingLabel"; const DEFAULT_LANE_COLOR = "#ffffff"; @@ -24,6 +25,7 @@ export type LaneChipProps = { laneColor?: string | null; maxWidth?: number; compact?: boolean; + naming?: boolean; className?: string; onClick?: () => void; } & Omit, "onClick" | "children">; @@ -33,6 +35,7 @@ export function LaneChip({ laneColor, maxWidth = 140, compact = false, + naming = false, className, onClick, style, @@ -50,11 +53,12 @@ export function LaneChip({ color, ...style, }; + const displayedName = naming ? "Naming lane…" : laneName; const label = ( <> - {laneName} + ); @@ -67,7 +71,7 @@ export function LaneChip({ onClick={onClick} className={chipClassName} style={chipStyle} - title={laneName} + title={displayedName} > {label} @@ -79,7 +83,7 @@ export function LaneChip({ {...rest} className={chipClassName} style={chipStyle} - title={laneName} + title={displayedName} > {label} diff --git a/apps/desktop/src/renderer/components/terminals/LaneNamingLabel.tsx b/apps/desktop/src/renderer/components/terminals/LaneNamingLabel.tsx index 9bb61b19d..29a62a355 100644 --- a/apps/desktop/src/renderer/components/terminals/LaneNamingLabel.tsx +++ b/apps/desktop/src/renderer/components/terminals/LaneNamingLabel.tsx @@ -1,15 +1,19 @@ -export function LaneNamingLabel({ - laneName, +import { cn } from "../ui/cn"; + +export function NamingPendingLabel({ + text, naming, + pendingLabel, }: { - laneName: string; + text: string; naming: boolean; + pendingLabel: string; }) { - if (!naming) return <>{laneName}; + if (!naming) return <>{text}; return ( - - Naming lane + + {pendingLabel} . . @@ -18,3 +22,13 @@ export function LaneNamingLabel({ ); } + +export function LaneNamingLabel({ + laneName, + naming, +}: { + laneName: string; + naming: boolean; +}) { + return ; +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index a2b77e109..e53d627a2 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -10,6 +10,7 @@ import { resetSessionHoverCardGroupForTests, } from "./SessionHoverCard"; import { setLaneNaming } from "../../state/laneNamingStore"; +import { setSessionMetadataGenerating } from "../../state/sessionMetadataGeneratingStore"; import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; const { navigateMock, sessionDeltaMock } = vi.hoisted(() => ({ @@ -38,6 +39,7 @@ afterEach(() => { // to be cleared or one test's card makes the next one open instantly. resetSessionHoverCardGroupForTests(); setLaneNaming("lane-1", false); + setSessionMetadataGenerating("session-1", null); vi.useRealTimers(); navigateMock.mockReset(); sessionDeltaMock.mockReset(); @@ -389,6 +391,61 @@ describe("SessionCard auto-naming status", () => { }); }); +describe("SessionCard metadata regeneration", () => { + it("masks the title, lane name, and status line while those fields regenerate", () => { + setSessionMetadataGenerating("session-1", { + fields: ["title", "laneName", "statusLine"], + laneId: "lane-1", + }); + render( + , + ); + + expect(screen.getByLabelText("Naming chat…")).toBeTruthy(); + expect(screen.getByLabelText("Naming lane…")).toBeTruthy(); + expect(screen.getByLabelText("Writing status…")).toBeTruthy(); + expect(screen.queryByText("Stop Haiku default")).toBeNull(); + expect(screen.queryByText("Tests are running")).toBeNull(); + }); + + it("only masks the requested field", () => { + setSessionMetadataGenerating("session-1", { + fields: ["title"], + laneId: "lane-1", + }); + render( + , + ); + + expect(screen.getByLabelText("Naming chat…")).toBeTruthy(); + expect(screen.queryByLabelText("Naming lane…")).toBeNull(); + expect(screen.getByText("Lane 1")).toBeTruthy(); + expect(screen.getByText(/Tests are running/i)).toBeTruthy(); + }); +}); + describe("SessionCard preview links", () => { it("links a PR token in a status note through the by-number PR route", () => { const onSelect = vi.fn(); diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index fa3ae3ace..f1786447d 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -41,7 +41,7 @@ import { import { relativeTimeCompact } from "../../lib/format"; import { GRID_SESSION_DND_MIME } from "../../lib/workGrid"; import { useAppStore } from "../../state/appStore"; -import { useLaneNaming } from "../../state/laneNamingStore"; +import { useLaneNamePending, useSessionFieldGenerating } from "../../state/sessionMetadataGeneratingStore"; import { useSessionDelta } from "./useSessionDelta"; import { cn } from "../ui/cn"; import { MONO_FONT } from "../lanes/laneDesignTokens"; @@ -67,7 +67,7 @@ import { isSessionSnoozed, sessionWokeMarker, snoozeWakeLabel } from "../../lib/ import { SessionStatusSlot } from "./SessionStatusSlot"; import { GitHubStackBadge } from "../prs/shared/GitHubStackBadge"; import { formatFutureDuration } from "../../../shared/sessionStatusPresentation"; -import { LaneNamingLabel } from "./LaneNamingLabel"; +import { LaneNamingLabel, NamingPendingLabel } from "./LaneNamingLabel"; /* ────────────────────────────────────────────────────────────────────────── The Work-sidebar session card. @@ -416,7 +416,9 @@ export const SessionCard = React.memo(function SessionCard({ return () => window.clearTimeout(timer); }, [canonicalPhase]); - const isAutoNaming = useLaneNaming(lane?.id ?? null); + const namingLane = useLaneNamePending(lane?.id ?? session.laneId); + const namingTitle = useSessionFieldGenerating(session.id, "title"); + const namingStatus = useSessionFieldGenerating(session.id, "statusLine"); // Brief warm highlight when the displayed title actually changes (e.g. the // deterministic/seed name is replaced by the AI name). Skipped on first mount. const [titleJustChanged, setTitleJustChanged] = React.useState(false); @@ -588,7 +590,7 @@ export const SessionCard = React.memo(function SessionCard({ - + , ); @@ -707,7 +709,7 @@ export const SessionCard = React.memo(function SessionCard({ ), @@ -950,7 +952,11 @@ export const SessionCard = React.memo(function SessionCard({ : "none", }} > - {primaryText} + {namingTitle ? ( + + ) : ( + primaryText + )} ); @@ -1045,7 +1051,14 @@ export const SessionCard = React.memo(function SessionCard({ {/* Line 3 — what it is doing, then the quiet meta. */}
- {previewLine ? ( + {namingStatus ? ( + + + + ) : previewLine ? ( void; - regeneratingMetadataSessionIds?: ReadonlySet; onSetChatTag?: ( session: TerminalSessionSummary, tag: string | null, @@ -185,7 +185,6 @@ function SessionContextMenuPanel({ onCopySessionId, onRename, onRegenerateMetadata, - regeneratingMetadataSessionIds, onSetChatTag, onCopySessionDeepLink, onOpenSessionInWeb, @@ -242,7 +241,7 @@ function SessionContextMenuPanel({ const isRunning = session.status === "running"; const isChat = isChatToolType(session.toolType); const isPrimaryLane = laneType === "primary"; - const isRegeneratingMetadata = regeneratingMetadataSessionIds?.has(session.id) ?? false; + const isRegeneratingMetadata = Boolean(useSessionMetadataGenerating(session.id)); const canonicalPhase = sessionCanonicalUiState(session).phase; const isActivelyRunning = sessionIsMidFlight(session); const canDismissNeedsYou = diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index e38bb373e..00133b7b9 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -15,7 +15,7 @@ import { sessionStatusBucket, } from "../../lib/terminalAttention"; import { useAppStore } from "../../state/appStore"; -import { useLaneNaming } from "../../state/laneNamingStore"; +import { useLaneNamePending } from "../../state/sessionMetadataGeneratingStore"; import { useCrossMachineLaneUnion, type CrossMachineLaneMarker, @@ -552,7 +552,7 @@ function StickyGroupHeader({ // a transformed ancestor sticks to the transformed box, so the header visibly // detaches from the top of the list mid-slide. const [sliding, setSliding] = useState(false); - const laneNaming = useLaneNaming(namingLaneId); + const namingLane = useLaneNamePending(namingLaneId); if (count === 0) return null; const isLane = variant === "lane"; const isQuietShelf = variant === "quiet-shelf"; @@ -565,7 +565,7 @@ function StickyGroupHeader({ // flexible text nodes in one row competing for width, which is what pushed the // PR badge off the edge. Non-lane group headers keep their sub-label. const showBranchCluster = !isLane && branchText.length > 0; - const resolvedLabel = laneNaming ? "Naming lane…" : label; + const resolvedLabel = namingLane ? "Naming lane…" : label; const laneHeaderTitle = branchText ? `${resolvedLabel} · ${branchText}` : resolvedLabel; // `laneSurfaceTint` is now consulted for its TEXT channel only. The background, // border, and left-accent it also returns are deliberately unused here: surface @@ -724,7 +724,7 @@ function StickyGroupHeader({ style={laneLabelColor ? { color: laneLabelColor } : undefined} title={laneHeaderTitle} > - + {showInlineCount ? ` (${count})` : null} {/* Branch sits immediately right of the label and expands to fill diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 67d873b83..09f2dc93c 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -36,6 +36,10 @@ import { } from "../../lib/sessions"; import { addSessionBesideTarget, removeSessionFromGrids } from "../../lib/workGrid"; import { buildWorkSessionTilingTree } from "./workSessionTiling"; +import { + getSessionMetadataGenerating, + setSessionMetadataGenerating, +} from "../../state/sessionMetadataGeneratingStore"; import type { DropEdge } from "../ui/paneTreeOps"; import { sortLanesForTabs } from "../lanes/laneUtils"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; @@ -173,7 +177,6 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { const [contextMenu, setContextMenu] = useState(null); const [infoPopover, setInfoPopover] = useState(null); const [sessionActionError, setSessionActionError] = useState(null); - const [regeneratingMetadataSessionIds, setRegeneratingMetadataSessionIds] = useState>(new Set()); const [deletingSessionId, setDeletingSessionId] = useState(null); const [selectedSessionIds, setSelectedSessionIds] = useState>(new Set()); const [selectionAnchorId, setSelectionAnchorId] = useState(null); @@ -1666,16 +1669,14 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }); }} onRegenerateMetadata={(session, fields, runtimePin) => { - if (regeneratingMetadataSessionIds.has(session.id)) return; - setRegeneratingMetadataSessionIds((current) => new Set(current).add(session.id)); + if (getSessionMetadataGenerating(session.id)) return; + setSessionMetadataGenerating(session.id, { fields, laneId: session.laneId }); const regenerate = runtimePin ? window.ade.agentChat.regenerateSessionMetadata({ sessionId: session.id, fields }, runtimePin) : window.ade.agentChat.regenerateSessionMetadata({ sessionId: session.id, fields }); - runSessionMutation(session.id, regenerate.finally(() => setRegeneratingMetadataSessionIds((current) => { - const next = new Set(current); - next.delete(session.id); - return next; - })), { + runSessionMutation(session.id, regenerate.finally(() => { + setSessionMetadataGenerating(session.id, null); + }), { actionName: "regenerate session metadata", errorLabel: "Generate metadata", refreshLabel: "metadata generation", @@ -1687,7 +1688,6 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }, }); }} - regeneratingMetadataSessionIds={regeneratingMetadataSessionIds} /> ({ ChatGitToolbar: ({ laneId }: { laneId: string }) => ( @@ -27,6 +28,7 @@ vi.mock("../shared/ClaudeCacheTtlBadge", () => ({ afterEach(() => { cleanup(); vi.unstubAllGlobals(); + setSessionMetadataGenerating("sess-header", null); }); describe("WorkSurfaceHeader", () => { @@ -140,6 +142,72 @@ describe("WorkSurfaceHeader", () => { expect(el.getAttribute("data-title-landed")).toBeNull(); }); + it("masks the title with the naming shimmer while metadata regenerates", () => { + setSessionMetadataGenerating("sess-header", { + fields: ["title"], + laneId: "lane-1", + }); + render( + , + ); + expect(screen.getByLabelText("Naming chat…")).toBeTruthy(); + expect(screen.getByText("Naming chat").closest("[data-title-generating]")?.getAttribute("data-title-generating")).toBe("true"); + expect(screen.queryByText("Stop Haiku default")).toBeNull(); + }); + + it("shimmers the title when regeneration lands a new name", () => { + setSessionMetadataGenerating("sess-header", { + fields: ["title"], + laneId: "lane-1", + }); + const { rerender } = render( + , + ); + act(() => { + setSessionMetadataGenerating("sess-header", null); + rerender( + , + ); + }); + const landed = screen.getByText("Skip first available model"); + expect(landed.getAttribute("data-title-landed")).toBe("true"); + expect(landed.className).toContain("ade-title-landed"); + }); + + it("does not shimmer the title when regeneration finishes without changing it", () => { + setSessionMetadataGenerating("sess-header", { + fields: ["title"], + laneId: "lane-1", + }); + const { rerender } = render( + , + ); + act(() => { + setSessionMetadataGenerating("sess-header", null); + rerender( + , + ); + }); + const title = screen.getByText("Stop Haiku default"); + expect(title.getAttribute("data-title-landed")).toBeNull(); + expect(title.className).not.toContain("ade-title-landed"); + }); + it("renders an optional title accessory after the title", () => { render( | null>(null); useEffect(() => { const prev = prevTitleRef.current; + const wasGenerating = prevGeneratingRef.current; prevTitleRef.current = title; - if (prev === title) return; + prevGeneratingRef.current = generating; + if (generating) return; + if (prev === title && !wasGenerating) return; + const landedFromDefault = PROVIDER_DEFAULT_TITLES.has(prev) && !PROVIDER_DEFAULT_TITLES.has(title); + const landedFromRegen = wasGenerating && prev !== title; if ( - PROVIDER_DEFAULT_TITLES.has(prev) - && !PROVIDER_DEFAULT_TITLES.has(title) + (landedFromDefault || landedFromRegen) && title.trim().length > 0 && !prefersReducedMotion() ) { setLanded(true); - // Safety clear in case animationend never fires (element re-measured, etc.). if (clearTimerRef.current) clearTimeout(clearTimerRef.current); clearTimerRef.current = setTimeout(() => setLanded(false), 800); } - }, [title]); + }, [title, generating]); useEffect(() => () => { if (clearTimerRef.current) clearTimeout(clearTimerRef.current); @@ -63,11 +69,16 @@ function WorkSurfaceTitle({ title }: { title: string }) { return ( setLanded(false)} > - {title} + {generating ? ( + + ) : ( + title + )} ); } @@ -267,6 +278,8 @@ export function WorkSurfaceHeader({ // so the chat/CLI surface looks identical in or out of a grid. const embeddedChrome = useFloatingPaneEmbeddedChrome(); const tileDragProps = embeddedChrome?.dragHandleProps ?? null; + const generatingTitle = useSessionFieldGenerating(lifecycleSessionId, "title"); + const namingLane = useLaneNamePending(laneId); return (
@@ -282,12 +295,13 @@ export function WorkSurfaceHeader({ {...(tileDragProps ?? {})} title={tileDragProps ? "Drag to rearrange or out of the grid" : undefined} > - + {titleAccessory} {showLaneChip && laneId && laneChipName ? ( diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index c29604497..f35472829 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -2223,6 +2223,7 @@ button:active, [role="button"]:active { .ade-update-installed-card, .ade-tool-bounce, .ade-title-landed, + .ade-naming-pending, .ade-fade-in, .ade-glow-pulse, .ade-glow-pulse-blue, @@ -2233,7 +2234,8 @@ button:active, [role="button"]:active { .pull-btn-flash { animation: none !important; } - .ade-title-landed { + .ade-title-landed, + .ade-naming-pending { background-image: none !important; -webkit-text-fill-color: currentColor !important; } @@ -3680,6 +3682,28 @@ button:active, [role="button"]:active { 50%, 100% { opacity: 1; } } +/* In-place shimmer while a chat title, lane name, or status line is being + regenerated — the same motion auto-create uses on "Naming lane…". */ +.ade-naming-pending, +.ade-title-landed { + background-image: linear-gradient( + 100deg, + currentColor 0%, + currentColor 35%, + color-mix(in srgb, var(--chat-accent, #a78bfa) 85%, #ffffff 15%) 50%, + currentColor 65%, + currentColor 100% + ); + background-size: 300% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.ade-naming-pending { + animation: ade-title-land 1.1s ease-in-out infinite; +} + .ade-lane-naming-dots > span { animation: ade-lane-naming-dot 1.2s ease-in-out infinite; animation-fill-mode: both; @@ -4260,18 +4284,6 @@ button:active, [role="button"]:active { } .ade-title-landed { - background-image: linear-gradient( - 100deg, - currentColor 0%, - currentColor 35%, - color-mix(in srgb, var(--chat-accent, #a78bfa) 85%, #ffffff 15%) 50%, - currentColor 65%, - currentColor 100% - ); - background-size: 300% 100%; - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; animation: ade-title-land 640ms ease-out 1; } diff --git a/apps/desktop/src/renderer/state/sessionMetadataGeneratingStore.ts b/apps/desktop/src/renderer/state/sessionMetadataGeneratingStore.ts new file mode 100644 index 000000000..7073e7587 --- /dev/null +++ b/apps/desktop/src/renderer/state/sessionMetadataGeneratingStore.ts @@ -0,0 +1,99 @@ +import { useStore } from "zustand"; +import { createStore } from "zustand/vanilla"; +import type { AgentChatSessionMetadataField } from "../../shared/types/chat"; +import { useLaneNaming } from "./laneNamingStore"; + +/** + * Ephemeral, renderer-only signal: which chat is mid explicit metadata + * regeneration, and which of the three fields the user asked to refresh. + * Session cards, the work-surface header, and lane labels mask those fields + * with the same in-place naming animation auto-create uses. + */ +export type SessionMetadataGeneratingEntry = { + fields: readonly AgentChatSessionMetadataField[]; + laneId: string; +}; + +type SessionMetadataGeneratingState = { + bySession: Record; + setGenerating: (sessionId: string, entry: SessionMetadataGeneratingEntry | null) => void; +}; + +const sessionMetadataGeneratingStore = createStore((set) => ({ + bySession: {}, + setGenerating: (sessionId, entry) => + set((state) => { + const id = sessionId.trim(); + if (!id) return state; + const current = state.bySession[id]; + if (!entry) { + if (!current) return state; + const next = { ...state.bySession }; + delete next[id]; + return { bySession: next }; + } + if ( + current + && current.laneId === entry.laneId + && current.fields.length === entry.fields.length + && current.fields.every((field, index) => field === entry.fields[index]) + ) { + return state; + } + return { + bySession: { + ...state.bySession, + [id]: { fields: [...entry.fields], laneId: entry.laneId }, + }, + }; + }), +})); + +export function setSessionMetadataGenerating( + sessionId: string, + entry: SessionMetadataGeneratingEntry | null, +): void { + sessionMetadataGeneratingStore.getState().setGenerating(sessionId, entry); +} + +export function getSessionMetadataGenerating( + sessionId: string, +): SessionMetadataGeneratingEntry | null { + return sessionMetadataGeneratingStore.getState().bySession[sessionId] ?? null; +} + +export function useSessionMetadataGenerating( + sessionId: string | null | undefined, +): SessionMetadataGeneratingEntry | null { + return useStore( + sessionMetadataGeneratingStore, + (state) => (sessionId ? state.bySession[sessionId] ?? null : null), + ); +} + +export function useSessionFieldGenerating( + sessionId: string | null | undefined, + field: AgentChatSessionMetadataField, +): boolean { + return useStore(sessionMetadataGeneratingStore, (state) => { + if (!sessionId) return false; + return state.bySession[sessionId]?.fields.includes(field) ?? false; + }); +} + +/** True when any chat in this lane is regenerating the lane name. */ +export function useLaneNameGenerating(laneId: string | null | undefined): boolean { + return useStore(sessionMetadataGeneratingStore, (state) => { + if (!laneId) return false; + return Object.values(state.bySession).some( + (entry) => entry.laneId === laneId && entry.fields.includes("laneName"), + ); + }); +} + +/** True when auto-create or explicit regen is writing this lane's name. */ +export function useLaneNamePending(laneId: string | null | undefined): boolean { + const autoNaming = useLaneNaming(laneId); + const regenerating = useLaneNameGenerating(laneId); + return autoNaming || regenerating; +} diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index cce024950..5f6e6f8e0 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -25,7 +25,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. OpenCode stream rendering gates every rendered content type on the assistant message role: `message.part.updated` events carry no role and user-message parts (including synthetic/ignored prompt context) ride the same event stream as assistant output, so text/reasoning deltas emit only for parts whose message id `message.updated` announced as `assistant` (`openCodeMessageRoleById` — unknown ids stay unrendered because OpenCode announces every message before its parts), synthetic/ignored parts are skipped outright, and image `file` parts still emit only for assistant-owned messages. Lane naming and chat auto-titling both run through the session-intelligence prompt path over the shared candidate chain in `sessionNaming.ts` (configured `titleModelId` when set, then the model the chat was launched with). An empty candidate list still uses a deterministic prompt-derived title/slug — it does not throw or skip naming. Branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. `buildAgentRuntimeEnv(managed)` stamps every SDK-backed provider process with `ADE_CHAT_SESSION_ID`, `ADE_DEFAULT_ROLE=agent` (or `orchestrator` for a lead), `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT`; the persistent guidance also names the concrete `--session ` argument for status commands so shared SDK servers do not depend on process-global env inheritance. `dismissPendingInputForSettlement` is the provider-neutral quieting boundary used by **Dismiss & settle**: it interrupts live Claude/Codex/OpenCode/Cursor/Droid turns best-effort, cancels local/provider waiters, removes Codex plan follow-ups, emits pending-input resolution, and persists an idle session before settle is written. It is single-flight per session (a second concurrent caller — a double-click, or a desktop and a phone dismissing at once — awaits the pass already running instead of starting a second one) and records which cards its own drains resolved so it never emits a second receipt for the same card. `settleCodexPendingInputs` is the single settle for a Codex turn: it answers each open app-server approval request, clears `runtime.approvals`, drains staged `pendingPlanFollowups`, cancels the local `codex` **and** `ade` cards, and emits exactly one `pending_input_resolved` per card; every path that ends a Codex turn calls it (`interrupt`, the local interrupt finish, the `turn/aborted` handler, runtime teardown, `thread/deleted`, the app-server `error`/`exit` handlers, and settlement). `settleClaudePendingApprovals` is the Claude counterpart for `canUseTool` waiters, which can only be answered on the query that raised them. When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns stop active subagents before emitting stopped `subagent_result`s, and every `subagent_result` is gated on a previously emitted `subagent_started` (tracked in `emittedSubagentStartIds`) so an interrupt can never emit a phantom stopped card for a subagent that never announced — terminal events clear both the taskId and agentId aliases. A plain Claude Code task run (`task_type` `other`, no agent metadata — e.g. "Re-run affected test files") is tracked for cleanup but never surfaces subagent rows. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Plan-mode transitions run through `claudePlanMode.ts` and emit a plan-mode notice carrying the resulting access mode, so the renderer composer chip updates from an authoritative value even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Every local Cursor turn is guarded by a 90 s first-event watchdog and at most one automatic recycle-and-resend (see [Cursor thread recycling and the first-event watchdog](#cursor-thread-recycling-and-the-first-event-watchdog)); an expired Cursor access token recycles the worker while resuming the *same* agent id, so the recovery is silent and the thread survives. Queued-steer settlement is claim-based: `settledSteerIds` is a per-session `WeakMap` of steer ids that have already had a delivered-or-cancelled notice emitted, claimed by every emitter that resolves a steer and re-opened whenever a steer goes back on the queue, so a runtime swap that detaches a queue the delivery attempt also drains cannot render two contradictory notices for one message. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so interrupt, reset/dispose, a native subagent exit, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind`; an active Claude parent receives SDK `priority: "next"` delivery, an active Codex parent receives `turn/steer`, and idle or provider-fallback parents receive the normal message path, while scheduled work remains boundary-delivered (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)). Spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Fork/import history seeding (`appendImportedChatEvents`) is chunked with event-loop yields, defers transcript flushes to chunk boundaries, and never publishes seeded historical envelopes to live event subscribers — readers load them via history APIs; live-publishing an entire source chat froze the app during fork handoff (ADE-122). The `chat.handoffSession` / `chat.prepareCrossMachineHandoff` runtime actions carry extended timeouts (120s daemon action, 150s IPC) because a brief handoff spans AI-brief generation plus first-message dispatch — the old 30s default fired a false timeout while the daemon-side handoff completed anyway. For orchestrator-lead sessions it builds the read-only capability services (`buildOrchestrationLeadReadServices` → `searchWorkspace` / `readLinearIssue` / `readPr` / `listProofArtifacts` / `mintDeeplink`), wiring each only when the backing service exists so a null service degrades to an omitted tool rather than a crash. Large service file. | | `apps/desktop/src/main/services/chat/chatRuntimeBudget.ts` | The process-wide warm-runtime budget. Owns `MAX_CONCURRENT_ACTIVE_RUNTIMES` (5) and `createChatRuntimeBudget()`, which chat services register with as `RuntimeBudgetParticipant`s (`countActiveRuntimes` + `listEvictableRuntimes`). `enforce(excludeSessionId)` releases at most one runtime per call — the globally least-recently-used releasable one across every registered participant — and yields when nothing is releasable. Constructed once per host (`main.ts`, `bootstrap.ts`) and passed to every project scope's `createAgentChatService`; a service constructed without one gets a private budget, which is the old per-service behaviour and the right answer for tests. Deliberately dependency-free of any runtime type so the LRU choice is testable without standing up a chat service. See [Session lifecycle](#session-lifecycle) below. | | `apps/desktop/src/main/services/chat/sessionNaming.ts` | Canonical home for everything the naming callers share — automatic lane identity, chat auto-title, explicit session-metadata regeneration, and the legacy lane-name suggestion — because each used to carry its own hand-copied chain that had already drifted. Owns the three system prompts and the lane-identity JSON schema, `MAX_NAMING_WORDS` (six words, handed to the model as a **guideline**: an over-long answer is clamped, never rejected, because a clamped real name beats a slug), `isProviderLevelNamingFailure` (a missing/unusable CLI, auth, quota, or an account that cannot run the model — including the "model is not supported when using X with a Y account" 400; it deliberately excludes "not supported for/on/by", which describes one model lacking a capability and must still retry a sibling), `buildNamingModelCandidates` / `buildSessionIntelligenceModelCandidates` (the user title setting, then this session's model; no hardcoded Haiku/mini/"first available" namer; an empty candidate list still uses the deterministic name and does not throw or skip naming), and `runNamingAcrossProviders` (walks the chain up to three attempts; a provider-level failure condemns every remaining model behind that provider, `run` returning null means "answered unusably" and the next candidate still gets a turn, and `shouldStop` abandons the chain when the user renames mid-flight). Session-metadata JSON parsing ignores extra keys and extracts fenced objects, so a Cursor Grok annotation does not discard a usable title. | -| `apps/desktop/src/main/services/chat/sessionMetadataService.ts` | Explicit title / lane-name / status-line regeneration. Walks the same session-intelligence candidate chain, then `deriveDeterministicSessionMetadata` when the list is empty or every model misses. Throws `The AI returned no usable session metadata.` only when that deterministic derivation also yields nothing. | +| `apps/desktop/src/main/services/chat/sessionMetadataService.ts` | Explicit title / lane-name / status-line regeneration in one JSON call. Gathers only the sources the requested fields need: status-only sends lane/chat identity plus the last assistant paragraphs (no transcript, sibling threads, or git); title-only sends this thread's transcript; lane-name sends sibling threads plus git vs remote/base. Walks the same session-intelligence candidate chain, then `deriveDeterministicSessionMetadata` when the list is empty or every model misses — except lane-name-only, which must not stamp this thread's kickoff onto the shared lane and throws `The AI returned no usable session metadata.` instead. Throws that same error when other field sets also have nothing usable. | | `apps/desktop/src/main/services/chat/spawnMissionOwnership.ts` | The single statement of who a spawned child chat is currently working for, so the policy is written and tested in one place instead of inline in `reportChildSpawnEnded`. Wake vs quiet is the child's persisted `spawnKind` (`subagent` always wakes; `peer` never does). `isHumanChildMessage` / `countHumanChildMessagesForTurn` / `formatHumanChildMessageAnnotation` name how many human messages landed in a finished turn so the next subagent wake can say `The user also sent N message(s) to this chat.` Parent dispatches, scheduled wakes, relays, host continuations, and any orchestration origin are not human messages. `HOST_AUTHORED_MESSAGE_PROVENANCE_KEYS` / `stripHostAuthoredMessageProvenance` export the same key list to every untrusted entry point (the ADE RPC edge, the automation action bridge) so provenance is always what the host observed, never what a caller asserted. | | `apps/desktop/src/main/services/chat/chatMentionService.ts` | Composer @-mention service (chats / lanes / terminals), created inside `agentChatService` with injected roster/transcript/PTY deps. Owns the keystroke-rate `chat.listMentionSuggestions` action (daemon-routed, read-only): one shared 1.5 s-TTL roster cache with a single in-flight promise collapses a typing burst into one sessions/lanes/terminals read, per-source failures degrade only their own candidate pool, and ranking/caps come from `shared/chatMentions.ts` (mixed best-match, not per-kind sections). Also owns send-time expansion: `applyChatMentionExpansion` rewrites send/steer args so the provider receives `` pointer blocks (identity attributes, a ≤1 KB CRLF-normalized neutralized preview, and literal `ade chat read` / `ade lanes show` / `ade terminal read` / `ade search` commands — double-quoted-only so they paste into sh, PowerShell, and cmd) while `displayText` keeps the user's literal chips. Idempotence uses a module-private Symbol marker (structured clone strips it, so nothing over IPC/sync can pre-mark), the single expansion owner on the steer side is `steerWithOptions`, and slash-command prompt rewrites re-attach blocks via `carryChatMentionBlocks`. Lane details never derive git state from `lane.status` (lanes are listed without a status probe and the unprobed default is indistinguishable from clean). Fires the content-free `onMentionsExpanded` analytics hook once per send that actually gained blocks. | | `apps/desktop/src/shared/chatMentions.ts` | Pure, surface-agnostic mention grammar shared by desktop, TUI, web preview mock, and (future) iOS: `@chat:` / `@lane:` / `@term:` token parsing derived from one prefix table (`CHAT_MENTION_KINDS` is the canonical kind order), word-boundary matching so emails never match, `renderChatMentionBlock` (attribute escaping + preview truncation on line boundaries + neutralization of forged `` tags and block headers so another session's transcript text cannot inject fake pointer blocks), `rankChatMentionSuggestions` (exact > prefix > substring > subsequence, recency tie-break, deterministic id tie-break — kind is not a sort key), and per-message caps (16 mixed menu rows, 12 expansions, 1024-char previews). Types live in `shared/types/chatMentions.ts`. | @@ -154,7 +154,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/ipc/registerIpc.ts` | Validates chat IPC args, exposes `agentChat.*` handlers (including scheduled-work create, list, per-job cancel, and per-chat pause), persists/retrieves parallel launch recovery state in `kv`, and refreshes the runtime scheduler after the global AI config pause changes. | | `apps/desktop/src/shared/ipc.ts` | `ade.agentChat.*` IPC channel constants. | -Explicit session metadata regeneration is a user-invoked, one-shot call through the selected chat runtime. It can refresh the chat title, lane name, status line, or all applicable fields together; the primary lane keeps its immutable name, and an explicit request may replace a title previously chosen by the user. Naming follows the user's title-model setting when one is set, then the chat's own model, then a deterministic name. An empty candidate list still uses that deterministic name — it does not throw or skip naming. Extra keys, fenced JSON, and surrounding prose are accepted; if every model still misses, ADE derives names from the conversation summary / latest output instead of throwing `The AI returned no usable session metadata.` +Explicit session metadata regeneration is a user-invoked, one-shot call through the selected chat runtime. All three fields are produced in a single JSON response even when the menu applies only some of them. The prompt is field-specific: regenerating only the status line sends the lane name, chat title, worktree folder, and the last two or three paragraphs of latest assistant output — not the full transcript, sibling threads, or git dump — and tells the model to copy the current title and lane name unchanged. `chatTitle` otherwise uses this thread's full conversation transcript; `laneName` uses every thread in the lane plus the git work that differs from the remote/base (`baseRef...HEAD`, commits, and uncommitted files). The primary lane keeps its immutable name, and an explicit request may replace a title previously chosen by the user. Naming follows the user's title-model setting when one is set, then the chat's own model, then a deterministic name. An empty candidate list still uses that deterministic name — it does not throw or skip naming. Extra keys, fenced JSON, and surrounding prose are accepted; if every model still misses, ADE derives names from the conversation summary / latest output instead of throwing `The AI returned no usable session metadata.` While a field is regenerating, the Work sidebar and chat header mask it in place with the same shimmering "Naming …" animation auto-created lanes use. ## Built-in browser authentication limits diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 133a7e671..9bbae39d1 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -962,10 +962,15 @@ Renderer surfaces: `useLaneNaming(laneId)` is the label-side subscription. Bridges the draft-launch flow (which owns the naming lifecycle) to singleton cards, hover details, and grouped lane headers in a separate component tree. +- `apps/desktop/src/renderer/state/sessionMetadataGeneratingStore.ts` — + ephemeral renderer-only store for explicit Generate title / lane / status + runs. Session cards, lane headers, and the work-surface title mask only + the requested fields with the same in-place "Naming …" animation + auto-create uses. `useLaneNamePending` ORs auto-create with lane-name regen. - `apps/desktop/src/renderer/components/terminals/LaneNamingLabel.tsx` — - shared reduced-motion-aware `Naming lane…` label with three animated dots; - callers supply the resolved naming state so visible and accessible labels - stay consistent. + shared reduced-motion-aware pending label (`NamingPendingLabel`) with three + animated dots; chat title, lane name, and status line pass their own + pending copy so visible and accessible labels stay consistent. - `apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx` — tabs/grid/single Work view. The grid mode renders through the shared `PaneTilingLayout`; the seed tree comes from diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index b750bb3a9..822fa2149 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -983,7 +983,14 @@ The right-click menu uses one grouped, liquid-glass menu vocabulary: clears live/restored pending input before writing settle instead of sending a synthetic decline. - Chat metadata generation makes one structured request for all three visible - fields and applies only the selected fields. It may intentionally replace a + fields and applies only the selected fields. A status-only refresh sends the + lane name, chat title, worktree folder, and last assistant paragraphs — not + the full transcript, sibling threads, or git dump. Title refresh still carries + this thread's full transcript; lane-name refresh still carries every other + thread in the lane plus the git work that differs from the remote/base. While + those fields generate, the session card and work-surface header mask them in + place with the same shimmering "Naming …" animation auto-created lanes use. + It may intentionally replace a manual title because the menu action is explicit user intent; edits made while the request is running win per field. Generate lane name is disabled for the primary lane, and a busy session disables duplicate generation.