From 9f495ba3ec7d2496a179882cc119e48ff7708d3b Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 4 Sep 2026 09:53:06 -0600 Subject: [PATCH] fix(chat): refine chat surface behavior --- apps/server/src/agents/manager.ts | 10 +- .../server/src/agents/tmux/command-builder.ts | 2 +- apps/server/src/chat/envelope.ts | 6 +- apps/server/src/chat/feed.ts | 19 ++- apps/server/src/jobs/service.ts | 8 +- apps/server/src/routes/agents/crud-routes.ts | 8 +- apps/server/src/server/mcp-handlers.ts | 5 +- apps/server/src/templates/service.ts | 4 +- apps/server/test/chat-feed.test.ts | 35 ++++- apps/server/test/chat-routes.test.ts | 2 +- apps/server/test/chat-service.test.ts | 2 +- apps/server/test/db/agent-manager.test.ts | 36 ++++- apps/server/test/jobs-routes.test.ts | 9 +- apps/server/test/jobs/continuation.test.ts | 2 +- apps/server/test/jobs/service.test.ts | 4 +- apps/server/test/mcp-handlers.test.ts | 5 +- apps/server/test/template-routes.test.ts | 5 +- apps/server/test/templates/service.test.ts | 1 + apps/server/test/tmux-command-builder.test.ts | 4 +- .../src/components/app/agent-pane.test.tsx | 40 +++++ apps/web/src/components/app/agent-pane.tsx | 49 ++++-- .../src/components/app/agents-view.test.tsx | 31 ++++ apps/web/src/components/app/agents-view.tsx | 9 +- .../src/components/app/chat/chat-entries.tsx | 8 +- .../web/src/components/app/chat/chat-feed.tsx | 5 +- .../components/app/chat/chat-pane.test.tsx | 14 ++ .../web/src/components/app/chat/chat-pane.tsx | 11 +- apps/web/src/components/ui/markdown.test.tsx | 30 ++++ apps/web/src/components/ui/markdown.tsx | 18 ++- docs/chat-surface-plan.md | 7 +- e2e/chat-surface.spec.ts | 141 +++++++++++++++++- packages/shared/src/chat-types.ts | 2 + 32 files changed, 460 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/components/ui/markdown.test.tsx diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index 3d220a77d..49c79a8fc 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -175,9 +175,13 @@ type CreateAgentInput = { * what the CLI receives: `prompt` is the message as the person or launching * agent wrote it (the MCP launch path wraps `initialPrompt` in a header the * feed should not repeat); `links` are the raw startup URLs the route also - * turned into url pins. Defaults to `initialPrompt` and no links. + * turned into url pins. Internal/generated startup prompts are deliberately + * omitted unless a caller explicitly supplies their user-authored context. */ - launchContext?: { prompt?: string; links?: string[] }; + launchContext?: { + prompt?: string; + links?: string[]; + }; initialPins?: AgentPin[]; initialFiles?: Array<{ fileName: string; @@ -590,7 +594,7 @@ export class AgentManager { return { id: launchPostId, agentId: p.id, - text: input.launchContext?.prompt ?? input.initialPrompt, + text: input.launchContext?.prompt, files: initialMedia.map((media) => ({ mediaId: media.mediaId })), links: input.launchContext?.links ?? [], pins: p.initialPins.map((pin) => ({ diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index 0b6266a07..35bd95e73 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -216,7 +216,7 @@ export function buildStartupPrompt( * and attachment schema. */ export const CHAT_SURFACE_GUIDANCE_RULE = - "Send user-facing replies and questions with dispatch_chat_post; use kind: question with options for finite choices. Terminal output remains in Console."; + "The user is reading Chat, not Console. Send every user-facing reply and question with dispatch_chat_post; use kind: question with options for finite choices."; /** * Build the numbered launch guidance text shared by all CLI agent types. diff --git a/apps/server/src/chat/envelope.ts b/apps/server/src/chat/envelope.ts index fcfbe4b35..425efe0b6 100644 --- a/apps/server/src/chat/envelope.ts +++ b/apps/server/src/chat/envelope.ts @@ -53,8 +53,8 @@ export function escapeEnvelopeMarkers(text: string): string { /** * The pane-injection envelope wrapping a user's Chat message. The trailing - * line tells the agent how to answer so the reply lands back in the Chat - * tab (docs/chat-surface-plan.md, "Injection envelope"). + * line gives the minimum routing reminder needed to thread the reply back + * into Chat; the persistent launch guidance explains why. * * `attachmentLines` (one `- kind: …` line each) are listed after the text and * before the closing marker so the agent can act on them. A blank text with @@ -79,7 +79,7 @@ export function buildChatEnvelope( `--- DISPATCH CHAT (id: ${messageId}) ---`, ...(body.length > 0 ? [safeBody] : []), "--- END DISPATCH CHAT ---", - `The user is reading the Chat tab, not this terminal — they only see what you post with dispatch_chat_post. Reply there (replyTo: "${messageId}"); terminal output alone will not reach them.`, + `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${messageId}").`, ].join("\n"); } diff --git a/apps/server/src/chat/feed.ts b/apps/server/src/chat/feed.ts index 8e44f8ce8..95d94b4ec 100644 --- a/apps/server/src/chat/feed.ts +++ b/apps/server/src/chat/feed.ts @@ -223,17 +223,25 @@ async function listAgentMessageEntries( recipient_agent_id: string; sender_name: string; recipient_name: string; + involves_child_agent: boolean; content: string; delivered: boolean | null; created_at: Date; at_key: string; }>( - `SELECT id, sender_agent_id, recipient_agent_id, sender_name, - recipient_name, content, delivered, created_at, + `SELECT m.id, m.sender_agent_id, m.recipient_agent_id, m.sender_name, + m.recipient_name, + EXISTS ( + SELECT 1 + FROM agents child + WHERE child.id IN (m.sender_agent_id, m.recipient_agent_id) + AND child.parent_agent_id = $1 + ) AS involves_child_agent, + m.content, m.delivered, m.created_at, ${AT_KEY_SQL} AS at_key - FROM agent_messages - WHERE (sender_agent_id = $1 OR recipient_agent_id = $1) ${clause} - ORDER BY created_at DESC, id DESC + FROM agent_messages m + WHERE (m.sender_agent_id = $1 OR m.recipient_agent_id = $1) ${clause} + ORDER BY m.created_at DESC, m.id DESC LIMIT $${params.length}`, params ); @@ -246,6 +254,7 @@ async function listAgentMessageEntries( senderName: row.sender_name, recipientAgentId: row.recipient_agent_id, recipientName: row.recipient_name, + involvesChildAgent: row.involves_child_agent, content: row.content, delivered: row.delivered, at: row.created_at.toISOString(), diff --git a/apps/server/src/jobs/service.ts b/apps/server/src/jobs/service.ts index 3e93d7c6e..8a6a372f1 100644 --- a/apps/server/src/jobs/service.ts +++ b/apps/server/src/jobs/service.ts @@ -200,9 +200,9 @@ export class JobService { model: agentConfig.model ?? undefined, cwd: job.directory, agentArgs: buildAgentArgs(agentType, prompt, agentConfig.fullAccess), - // The CLI receives the prompt through agentArgs; the Chat feed's - // launch post needs it stated separately. - launchContext: { prompt }, + // The CLI receives generated job-run scaffolding through agentArgs; + // Chat shows only the user-authored job prompt. + launchContext: { prompt: resolvedPrompt }, fullAccess: agentConfig.fullAccess, ...templateWorktreeConfig(agentConfig), jobRunId: run.id, @@ -471,7 +471,7 @@ export class JobService { prompt, agentConfig.fullAccess ), - launchContext: { prompt }, + launchContext: { prompt: resolvedPrompt }, fullAccess: agentConfig.fullAccess, ...templateWorktreeConfig(agentConfig), jobRunId: run.id, diff --git a/apps/server/src/routes/agents/crud-routes.ts b/apps/server/src/routes/agents/crud-routes.ts index 22a421207..ef2fc8993 100644 --- a/apps/server/src/routes/agents/crud-routes.ts +++ b/apps/server/src/routes/agents/crud-routes.ts @@ -323,7 +323,13 @@ export async function registerAgentCrudRoutes( !isTerminalAgent && typeof body.initialPrompt === "string" ? body.initialPrompt.trim() || undefined : undefined, - launchContext: { links: !isTerminalAgent ? (startupLinks ?? []) : [] }, + launchContext: { + prompt: + !isTerminalAgent && typeof body.initialPrompt === "string" + ? body.initialPrompt.trim() || undefined + : undefined, + links: !isTerminalAgent ? (startupLinks ?? []) : [], + }, initialPins: !isTerminalAgent ? startupPins : [], initialFiles: !isTerminalAgent ? startupFiles : [], }); diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 096e26b8a..8355fb7ae 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -646,8 +646,9 @@ async function handleLaunchAgent( launchedByAgentId: agentId, cliSessionId, initialPrompt: buildLaunchedAgentInitialPrompt(agentId, prompt, child), - // The feed shows the prompt as the launcher wrote it, not the header. - launchContext: { prompt }, + // The feed shows the prompt as the launcher wrote it, not the rendered + // template instructions or the launch header. + launchContext: { prompt: input.prompt }, templateId: input.templateId, }); diff --git a/apps/server/src/templates/service.ts b/apps/server/src/templates/service.ts index f4e098e1e..2eb1c7260 100644 --- a/apps/server/src/templates/service.ts +++ b/apps/server/src/templates/service.ts @@ -222,7 +222,9 @@ export class TemplateService { model: resolvedModel, cwd, initialPrompt: finalPrompt, - launchContext: { links: !isTerminal ? (input.startupLinks ?? []) : [] }, + launchContext: { + links: !isTerminal ? (input.startupLinks ?? []) : [], + }, fullAccess: !isTerminal && template.fullAccess, ...(isTerminal ? { useWorktree: false } diff --git a/apps/server/test/chat-feed.test.ts b/apps/server/test/chat-feed.test.ts index 1ced4095a..2a84a7392 100644 --- a/apps/server/test/chat-feed.test.ts +++ b/apps/server/test/chat-feed.test.ts @@ -15,15 +15,18 @@ let store: ChatStore; const A = "agt_feed_a"; const OTHER = "agt_feed_other"; +const ARCHIVED_CHILD = "agt_feed_archived_child"; beforeAll(async () => { pool = await setupTestDb(); await runTestMigrations(); store = new ChatStore(pool); await pool.query( - `INSERT INTO agents (id, name, cwd, status) - VALUES ($1, 'Feed A', '/tmp', 'running'), ($2, 'Other', '/tmp', 'running')`, - [A, OTHER] + `INSERT INTO agents (id, name, cwd, status, parent_agent_id, deleted_at) + VALUES ($1, 'Feed A', '/tmp', 'running', NULL, NULL), + ($2, 'Other', '/tmp', 'running', NULL, NULL), + ($3, 'Archived child', '/tmp', 'stopped', $1, NOW())`, + [A, OTHER, ARCHIVED_CHILD] ); }); @@ -154,6 +157,32 @@ describe("composeChatFeed", () => { expect(page3.entries.map((e) => e.type)).toEqual(["status"]); }); + it("marks both directions of archived child conversations", async () => { + await pool.query( + `INSERT INTO agent_messages + (id, sender_agent_id, recipient_agent_id, sender_name, recipient_name, + content, delivered, created_at) + VALUES (gen_random_uuid(), $2, $1, 'Archived child', 'Feed A', 'in', true, $3), + (gen_random_uuid(), $1, $2, 'Feed A', 'Archived child', 'out', true, $4), + (gen_random_uuid(), $5, $1, 'Other', 'Feed A', 'peer', true, $4)`, + [A, ARCHIVED_CHILD, at(10), at(11), OTHER] + ); + + const messages = (await composeChatFeed(store, A)).entries.filter( + (entry) => entry.type === "agent_message" + ); + expect(messages).toHaveLength(3); + expect( + messages + .map((entry) => [entry.content, entry.involvesChildAgent]) + .sort(([left], [right]) => String(left).localeCompare(String(right))) + ).toEqual([ + ["in", true], + ["out", true], + ["peer", false], + ]); + }); + it("never drops or repeats rows that share a timestamp across sources", async () => { // Nine rows at the same instant (three per source kind plus chat) with // microsecond-identical created_at, paged two at a time. diff --git a/apps/server/test/chat-routes.test.ts b/apps/server/test/chat-routes.test.ts index 3fa698db9..1a6666d2e 100644 --- a/apps/server/test/chat-routes.test.ts +++ b/apps/server/test/chat-routes.test.ts @@ -566,7 +566,7 @@ describe("chat routes with a deliverable terminal", () => { `--- DISPATCH CHAT (id: ${body.message.id}) ---`, "please do X", "--- END DISPATCH CHAT ---", - `The user is reading the Chat tab, not this terminal — they only see what you post with dispatch_chat_post. Reply there (replyTo: "${body.message.id}"); terminal output alone will not reach them.`, + `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${body.message.id}").`, ].join("\n") ); expect(published).toEqual([ diff --git a/apps/server/test/chat-service.test.ts b/apps/server/test/chat-service.test.ts index ba552a3e2..ea78a745b 100644 --- a/apps/server/test/chat-service.test.ts +++ b/apps/server/test/chat-service.test.ts @@ -713,7 +713,7 @@ describe("ChatService user workflows", () => { "- pin: URL — http://x", "- link: https://example.com/spec — Spec", "--- END DISPATCH CHAT ---", - `The user is reading the Chat tab, not this terminal — they only see what you post with dispatch_chat_post. Reply there (replyTo: "${res.message.id}"); terminal output alone will not reach them.`, + `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${res.message.id}").`, ].join("\n") ); }); diff --git a/apps/server/test/db/agent-manager.test.ts b/apps/server/test/db/agent-manager.test.ts index 43dd4a860..ae19f84dd 100644 --- a/apps/server/test/db/agent-manager.test.ts +++ b/apps/server/test/db/agent-manager.test.ts @@ -259,7 +259,10 @@ describe("AgentManager", () => { cwd: "/tmp", useWorktree: false, initialPrompt: "Build the widget", - launchContext: { links: ["https://example.com/spec"] }, + launchContext: { + prompt: "Build the widget", + links: ["https://example.com/spec"], + }, initialPins: [ { label: "example.com", @@ -353,6 +356,7 @@ describe("AgentManager", () => { useWorktree: false, launchedByAgentId: parent.id, initialPrompt: "Go", + launchContext: { prompt: "Go" }, }); expect((await launchPosts(independent.id))[0]).toMatchObject({ launched_by_agent_id: parent.id, @@ -373,6 +377,7 @@ describe("AgentManager", () => { useWorktree: false, parentAgentId: parent.id, initialPrompt: "Pretend I am the parent", + launchContext: { prompt: "Pretend I am the parent" }, }); expect((await launchPosts(child.id))[0]).toMatchObject({ author_kind: "user", @@ -456,7 +461,10 @@ describe("AgentManager", () => { type: "claude", useWorktree: false, initialPrompt: "Build the widget", - launchContext: { links: ["https://example.com/spec"] }, + launchContext: { + prompt: "Build the widget", + links: ["https://example.com/spec"], + }, initialFiles: [ { fileName: "brief.md", @@ -501,6 +509,7 @@ describe("AgentManager", () => { type: "claude", useWorktree: false, initialPrompt: "Build the widget", + launchContext: { prompt: "Build the widget" }, }); expect(await launchPosts(agent.id)).toHaveLength(1); const setupScript = await readFile( @@ -511,6 +520,27 @@ describe("AgentManager", () => { expect(setupScript).toContain("Build the widget"); }); + it("keeps generated startup prompts out of Chat while retaining Chat guidance", async () => { + await withChatSurface(async () => { + const agent = await manager.createAgent({ + cwd: "/tmp", + type: "claude", + useWorktree: false, + initialPrompt: "Internal launch instructions", + }); + expect(await launchPosts(agent.id)).toEqual([]); + const setupScript = await readFile( + `/tmp/dispatch_setup_${agent.id}.sh`, + "utf-8" + ); + expect(setupScript).toContain("Internal launch instructions"); + expect(setupScript).toContain( + "The user is reading Chat, not Console." + ); + expect(setupScript).not.toContain("--- DISPATCH CHAT"); + }); + }); + it("starts the runtime without waiting on a write that never resolves", async () => { const warn = vi.fn(); const stuckManager = new AgentManager( @@ -586,6 +616,7 @@ describe("AgentManager", () => { type: "claude", useWorktree: false, initialPrompt: "Go", + launchContext: { prompt: "Go" }, }); expect(warn).toHaveBeenCalledWith( expect.objectContaining({ @@ -703,6 +734,7 @@ describe("AgentManager", () => { type: "claude", useWorktree: false, initialPrompt: "Go", + launchContext: { prompt: "Go" }, }); expect(warn).toHaveBeenCalledWith( expect.objectContaining({ agentId: agent.id }), diff --git a/apps/server/test/jobs-routes.test.ts b/apps/server/test/jobs-routes.test.ts index 343d87b3a..28564c832 100644 --- a/apps/server/test/jobs-routes.test.ts +++ b/apps/server/test/jobs-routes.test.ts @@ -326,12 +326,9 @@ describe("POST /api/v1/jobs/run", () => { origin: "launch", attachments: [], }); - // The post carries the same prompt the CLI receives through agentArgs: - // the job header plus the job's own prompt. - expect(posts.rows[0].text).toContain(`Run ID: ${body.runId}`); - expect(posts.rows[0].text).toContain( - "\nJob prompt:\nSweep the stale branches" - ); + // Chat shows only the user-authored prompt, not generated job lifecycle + // scaffolding (which still reaches the CLI through agentArgs). + expect(posts.rows[0].text).toBe("Sweep the stale branches"); await ctx.pool.query( `UPDATE job_runs SET status = 'completed' WHERE id = $1`, [body.runId] diff --git a/apps/server/test/jobs/continuation.test.ts b/apps/server/test/jobs/continuation.test.ts index 57011e552..0afd683ae 100644 --- a/apps/server/test/jobs/continuation.test.ts +++ b/apps/server/test/jobs/continuation.test.ts @@ -631,7 +631,7 @@ describe("continuation jobs", () => { useWorktree: true, baseBranch: "main", launchContext: { - prompt: expect.stringContaining("Continuation chain: chain-1"), + prompt: "Continue the work.", }, // No stored branch name, so each iteration gets its own generated branch // rather than colliding with the previous iteration's. diff --git a/apps/server/test/jobs/service.test.ts b/apps/server/test/jobs/service.test.ts index ad9b39137..b3acdc280 100644 --- a/apps/server/test/jobs/service.test.ts +++ b/apps/server/test/jobs/service.test.ts @@ -319,9 +319,9 @@ describe("JobService", () => { expect.objectContaining({ jobRunId: result.runId, name: `job-Rename_Test-${result.runId.slice(0, 8)}`, - // The Chat launch post gets the job prompt the CLI receives. + // The Chat launch post gets only the user-authored job prompt. launchContext: { - prompt: expect.stringContaining(`Run ID: ${result.runId}`), + prompt: "Test prompt", }, }) ); diff --git a/apps/server/test/mcp-handlers.test.ts b/apps/server/test/mcp-handlers.test.ts index 8a15e1d2a..5f80a5a0e 100644 --- a/apps/server/test/mcp-handlers.test.ts +++ b/apps/server/test/mcp-handlers.test.ts @@ -1316,7 +1316,7 @@ describe("createMcpHandlers", () => { "--- DISPATCH CHAT (id: post-1) ---", created.initialPrompt, "--- END DISPATCH CHAT ---", - 'The user is reading the Chat tab, not this terminal — they only see what you post with dispatch_chat_post. Reply there (replyTo: "post-1"); terminal output alone will not reach them.', + 'The user only sees Chat — reply with dispatch_chat_post (replyTo: "post-1").', ].join("\n") ); expect(turn).toContain('You were launched by Dispatch agent "agt_test1"'); @@ -1561,6 +1561,9 @@ describe("createMcpHandlers", () => { expect(initialPrompt).toContain("Build this idea:"); expect(initialPrompt).toContain("fix the launch bug"); expect(initialPrompt).not.toContain("{{D:"); + expect( + deps.agentManager.createAgent.mock.calls[0][0].launchContext + ).toEqual({ prompt: "fix the launch bug" }); }); it("appends the caller's prompt to the template's prompt", async () => { diff --git a/apps/server/test/template-routes.test.ts b/apps/server/test/template-routes.test.ts index c4c0b5fad..f8e4b7a80 100644 --- a/apps/server/test/template-routes.test.ts +++ b/apps/server/test/template-routes.test.ts @@ -307,7 +307,8 @@ describe("POST /api/v1/templates/:id/launch", () => { value: "https://example.com/spec", }), ]); - // ...but the launch post shows the URL once, as a link attachment. + // ...but the launch post shows the URL once, as a link attachment, while + // template runtime instructions stay out of Chat. const posts = await ctx.pool.query( `SELECT text, origin, attachments FROM agent_chat_messages WHERE agent_id = $1`, @@ -315,7 +316,7 @@ describe("POST /api/v1/templates/:id/launch", () => { ); expect(posts.rows).toHaveLength(1); expect(posts.rows[0]).toMatchObject({ - text: "Read the spec", + text: "", origin: "launch", attachments: [{ type: "link", url: "https://example.com/spec" }], }); diff --git a/apps/server/test/templates/service.test.ts b/apps/server/test/templates/service.test.ts index 92c9d82a4..105b57110 100644 --- a/apps/server/test/templates/service.test.ts +++ b/apps/server/test/templates/service.test.ts @@ -89,6 +89,7 @@ describe("TemplateService.launchTemplate", () => { expect.objectContaining({ type: "codex", initialPrompt: "Review src/app.tsx", + launchContext: { links: [] }, }) ); }); diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index c73348e35..d7112eb40 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -199,7 +199,7 @@ describe("buildStartupTurn — the Chat launch envelope", () => { "- file: /media/agt_x/brief-2026.md (text/markdown, 300 B)", "- pin: Ticket — DIS-42", "--- END DISPATCH CHAT ---", - `The user is reading the Chat tab, not this terminal — they only see what you post with dispatch_chat_post. Reply there (replyTo: "${POST_ID}"); terminal output alone will not reach them.`, + `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${POST_ID}").`, ].join("\n") ); }); @@ -1182,7 +1182,7 @@ describe("buildLaunchGuidance — trimmed variant", () => { describe("buildLaunchGuidance — chat surface rule", () => { const RULE = - "Send user-facing replies and questions with dispatch_chat_post; use kind: question with options for finite choices. Terminal output remains in Console."; + "The user is reading Chat, not Console. Send every user-facing reply and question with dispatch_chat_post; use kind: question with options for finite choices."; it("is absent by default and when the flag is off", () => { expect( diff --git a/apps/web/src/components/app/agent-pane.test.tsx b/apps/web/src/components/app/agent-pane.test.tsx index 67c3ef9ac..99ae985ab 100644 --- a/apps/web/src/components/app/agent-pane.test.tsx +++ b/apps/web/src/components/app/agent-pane.test.tsx @@ -168,6 +168,24 @@ describe("AgentViewToggle", () => { expect(screen.queryByTestId("agent-view-chat-unread")).toBeNull(); }); + it("slides one compact indicator between views", () => { + const view = render(); + const toggle = screen.getByTestId("agent-view-toggle"); + const track = screen.getByTestId("agent-view-track"); + const indicator = screen.getByTestId("agent-view-indicator"); + expect(toggle.className).toContain("h-6"); + expect(toggle.className).toContain("w-[7.75rem]"); + expect(toggle.className).toContain("pointer-coarse:h-11"); + expect(track.className).toContain("h-6"); + expect(indicator.className).toContain("transition-transform"); + expect(indicator.className).not.toContain( + "translate-x-[calc(100%+0.25rem)]" + ); + + view.rerender(); + expect(indicator.className).toContain("translate-x-[calc(100%+0.25rem)]"); + }); + it("opens chat filters and reports child-agent visibility changes", () => { const onShowChildAgentsChange = vi.fn(); const view = render( @@ -198,6 +216,28 @@ describe("AgentViewToggle", () => { screen.getByTestId("chat-filters-trigger").getAttribute("aria-label") ).toBe("Chat filters, child-agent messages hidden"); }); + + it("keeps the filter icon unchanged inside a compact visible surface", () => { + render(); + const trigger = screen.getByTestId("chat-filters-trigger"); + const surface = screen.getByTestId("chat-filters-surface"); + const icon = screen.getByTestId("chat-filters-icon"); + + expect(trigger.className).toContain("pointer-coarse:h-11"); + expect(trigger.className).toContain("hover:bg-transparent"); + expect(surface.className).toContain("h-6"); + expect(surface.className).toContain("w-6"); + expect(icon.getAttribute("class")).toContain("h-3.5"); + expect(icon.getAttribute("class")).toContain("w-3.5"); + }); + + it("keeps the complete control group from shrinking under header pressure", () => { + render(); + const controls = screen.getByTestId("agent-view-toggle").parentElement; + + expect(controls?.className).toContain("shrink-0"); + expect(controls?.className).not.toContain("min-w-0"); + }); }); describe("AgentPane", () => { diff --git a/apps/web/src/components/app/agent-pane.tsx b/apps/web/src/components/app/agent-pane.tsx index a178a5cc8..d5593fb03 100644 --- a/apps/web/src/components/app/agent-pane.tsx +++ b/apps/web/src/components/app/agent-pane.tsx @@ -41,7 +41,7 @@ export function AgentViewToggle({ ? "Chat filters" : "Chat filters, child-agent messages hidden"; return ( -
+
+ @@ -94,11 +107,22 @@ export function AgentViewToggle({ title={filtersLabel} data-testid="chat-filters-trigger" className={cn( - "h-7 w-7 rounded-full pointer-coarse:h-11 pointer-coarse:w-11", - !showChildAgents && "bg-primary/10 text-primary" + "group h-7 w-7 rounded-full p-0 hover:bg-transparent focus-visible:ring-0 pointer-coarse:h-11 pointer-coarse:w-11", + !showChildAgents && "text-primary" )} > - + + + @@ -219,7 +243,10 @@ export function AgentPane({ ) : null} {chatEnabled ? (
{/* diff --git a/apps/web/src/components/app/agents-view.test.tsx b/apps/web/src/components/app/agents-view.test.tsx index bb44fc1ed..43fb8648d 100644 --- a/apps/web/src/components/app/agents-view.test.tsx +++ b/apps/web/src/components/app/agents-view.test.tsx @@ -660,6 +660,37 @@ describe("AgentsView agent pane", () => { expect(propsOf("AgentPane").active).toBe(true); }); + it("keeps the empty workspace Console-only even when Chat is enabled", () => { + Object.assign(H.state, { + agents: [], + validatedSelectedAgentId: null, + connState: "disconnected", + connectedAgentId: null, + chatEnabled: true, + }); + mount({ path: "/agents" }); + + expect(propsOf("AgentsViewHeader").chatEnabled).toBe(false); + expect(propsOf("AgentPane").chatEnabled).toBe(false); + }); + + it("keeps terminal agents Console-only and omits the split view switch", () => { + Object.assign(H.state, { + agents: [makeAgent({ id: "terminal-1", type: "terminal" })], + validatedSelectedAgentId: "terminal-1", + connState: "connected", + connectedAgentId: "terminal-1", + chatEnabled: true, + isSplit: true, + splitState: { left: "agent", right: "changes" }, + }); + mount({ path: "/agents/terminal-1" }); + + expect(propsOf("AgentsViewHeader").chatEnabled).toBe(false); + expect(propsOf("AgentPane").chatEnabled).toBe(false); + expect(propsOf("CenterPaneSplit").agentHeaderAccessory).toBeNull(); + }); + it("keeps the pane mounted but inactive under the Changes tab", () => { focusOn("a1"); H.state.changesMatch = true; diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index c4c136736..a4fc68a71 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -234,9 +234,12 @@ export function AgentsView({ : null; // The flag as it applies to the agent in focus: a terminal session has no // CLI to chat with, so it keeps the plain Terminal tab and Console-only - // pane however the flag is set. + // pane however the flag is set. An empty workspace likewise has no Chat + // target and should not render the Agent-pane view switch. const chatEnabled = - chatSurfaceEnabled && agentSupportsChat(focusedAgent?.type); + chatSurfaceEnabled && + focusedAgent !== null && + agentSupportsChat(focusedAgent.type); const activeTab: CenterTab = changesMatch ? "changes" : whiteboardMatch @@ -637,7 +640,7 @@ export function AgentsView({ /> ) : null; const splitAgentHeaderAccessory = - isSplit && agentPaneVisible ? ( + isSplit && agentPaneVisible && chatEnabled ? ( ) : null} {message.text ? ( -
{message.text}
+
+ {message.text} +
) : null} +
{blocks.map((block) => { if (block.kind === "statuses") { return ( diff --git a/apps/web/src/components/app/chat/chat-pane.test.tsx b/apps/web/src/components/app/chat/chat-pane.test.tsx index 9beaf10b5..75da75c26 100644 --- a/apps/web/src/components/app/chat/chat-pane.test.tsx +++ b/apps/web/src/components/app/chat/chat-pane.test.tsx @@ -208,6 +208,20 @@ describe("filterChildAgentMessages", () => { ) ).toEqual(["other-agent", "human-chat"]); }); + + it("uses feed lineage when an archived child is absent from the live list", () => { + const archivedChild = { + ...childMessage("archived-child", "agt_archived", "agt_1"), + involvesChildAgent: true, + }; + expect( + filterChildAgentMessages( + [...entries, archivedChild], + new Set(), + false + ).map((entry) => entry.id) + ).toEqual(["from-child", "to-child", "other-agent", "human-chat"]); + }); }); describe("ChatPane", () => { diff --git a/apps/web/src/components/app/chat/chat-pane.tsx b/apps/web/src/components/app/chat/chat-pane.tsx index 22055b0d6..50ed8920e 100644 --- a/apps/web/src/components/app/chat/chat-pane.tsx +++ b/apps/web/src/components/app/chat/chat-pane.tsx @@ -65,11 +65,12 @@ export function filterChildAgentMessages( childAgentIds: ReadonlySet, showChildAgents: boolean ): ChatFeedEntry[] { - if (showChildAgents || childAgentIds.size === 0) return [...entries]; + if (showChildAgents) return [...entries]; return entries.filter( (entry) => entry.type !== "agent_message" || - (!childAgentIds.has(entry.senderAgentId) && + (!entry.involvesChildAgent && + !childAgentIds.has(entry.senderAgentId) && !childAgentIds.has(entry.recipientAgentId)) ); } @@ -369,7 +370,7 @@ export function ChatPane({ return (
@@ -382,7 +383,7 @@ export function ChatPane({ onLoadCapture={() => { if (following) scrollToBottom(); }} - className="h-full overflow-y-auto overscroll-contain py-2" + className="h-full min-w-0 max-w-full overflow-x-hidden overflow-y-auto overscroll-contain py-2" > {feed.hasOlder ? (
@@ -493,7 +494,7 @@ export function ChatPane({
diff --git a/apps/web/src/components/ui/markdown.test.tsx b/apps/web/src/components/ui/markdown.test.tsx new file mode 100644 index 000000000..4a3c154cb --- /dev/null +++ b/apps/web/src/components/ui/markdown.test.tsx @@ -0,0 +1,30 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { Markdown } from "./markdown"; + +vi.mock("@/components/ui/markdown-mermaid", () => ({ + MermaidBlock: () => null, +})); +vi.mock("@/components/ui/markdown-mermaid-theme", () => ({ + useMermaidTheme: () => "default", +})); + +afterEach(cleanup); + +describe("MarkdownDefault overflow", () => { + it("keeps prose unclipped and gives tables their own horizontal scroller", () => { + const { container } = render( + {`| First | Second | Third | +| --- | --- | --- | +| alpha | beta | gamma |`} + ); + + const prose = container.firstElementChild as HTMLElement; + const scroller = screen.getByTestId("markdown-table-scroll"); + expect(prose.className).not.toContain("overflow-x-hidden"); + expect(scroller.className).toContain("overflow-x-auto"); + expect(scroller.querySelector("table")).not.toBeNull(); + }); +}); diff --git a/apps/web/src/components/ui/markdown.tsx b/apps/web/src/components/ui/markdown.tsx index bdd5796b1..b87349c4d 100644 --- a/apps/web/src/components/ui/markdown.tsx +++ b/apps/web/src/components/ui/markdown.tsx @@ -154,10 +154,10 @@ function MarkdownDefault({ return (
+ + + ); + }, pre({ children }) { const block = getCodeBlock(children); if (block?.className === "language-mermaid") { diff --git a/docs/chat-surface-plan.md b/docs/chat-surface-plan.md index 157a84092..962a0858f 100644 --- a/docs/chat-surface-plan.md +++ b/docs/chat-surface-plan.md @@ -107,7 +107,7 @@ write to `agent_chat_messages`. The web also invalidates the feed on --- DISPATCH CHAT (id: ) --- --- END DISPATCH CHAT --- -The user is reading the Chat tab, not this terminal — they only see what you post with dispatch_chat_post. Reply there (replyTo: ""); terminal output alone will not reach them. +Reply with dispatch_chat_post (replyTo: ""). ``` Answers to a question use the same envelope with the chosen label as text. @@ -429,7 +429,7 @@ Decided 2026-09-03. Brad's ask: when someone launches an agent with context (an initial message, startup files, links, pins), show it in the Chat right when the agent starts — otherwise it is only visible in the Console. -- **One post per launch.** `AgentManager.createAgent` records the context +- **One post per launch with user-visible context.** `AgentManager.createAgent` records the context through a `LaunchContextRecorder` attached post-construction (`ChatService.recordLaunchContext`), after the agent row and its media rows exist and alongside the runtime launch, so every launch path (create @@ -437,7 +437,8 @@ when the agent starts — otherwise it is only visible in the Console. best-effort: it never blocks the runtime launch, and `createAgent` waits for it at most 5s before returning (a late write still lands). The post is a user message, kind `reply`, `delivered: true` (the prompt reaches the CLI by the - normal launch path; nothing is injected), text = the initial prompt, and + normal launch path; nothing is injected), text = the explicitly supplied + user-authored launch context (never generated/internal startup guidance), and attachments = a `file` per startup media row (resolved by `mediaId`), a `link` per startup link, and a `pin` per initial pin — except a url pin the route made from one of the links, so the URL is not shown twice. A diff --git a/e2e/chat-surface.spec.ts b/e2e/chat-surface.spec.ts index 3b2b3fc53..8c353dd54 100644 --- a/e2e/chat-surface.spec.ts +++ b/e2e/chat-surface.spec.ts @@ -572,7 +572,7 @@ test.describe("Chat surface", () => { }) => { await setChatSurface(request, true); const agent = await createAgentViaAPI(request, { - name: `e2e-chat-touch-${Date.now()}`, + name: `e2e-chat-touch-with-a-realistically-long-agent-task-name-${Date.now()}`, }); const protocol = process.env.TLS_CERT ? "https" : "http"; @@ -607,6 +607,35 @@ test.describe("Chat surface", () => { ) .toBeGreaterThanOrEqual(44); } + const track = touchPage.getByTestId("agent-view-track"); + const filterSurface = touchPage.getByTestId("chat-filters-surface"); + const filterIcon = touchPage.getByTestId("chat-filters-icon"); + await expect + .poll(async () => { + const trackBox = (await track.boundingBox())!; + const surfaceBox = (await filterSurface.boundingBox())!; + const iconBox = (await filterIcon.boundingBox())!; + return { + trackHeight: Math.round(trackBox.height), + surfaceWidth: Math.round(surfaceBox.width), + surfaceHeight: Math.round(surfaceBox.height), + centerDelta: Math.round( + surfaceBox.y + + surfaceBox.height / 2 - + (trackBox.y + trackBox.height / 2) + ), + iconWidth: Math.round(iconBox.width), + iconHeight: Math.round(iconBox.height), + }; + }) + .toEqual({ + trackHeight: 24, + surfaceWidth: 24, + surfaceHeight: 24, + centerDelta: 0, + iconWidth: 14, + iconHeight: 14, + }); // The header grew to hold it rather than clipping it. const controls = toggle.locator("xpath=.."); const header = controls.locator("xpath=.."); @@ -617,6 +646,33 @@ test.describe("Chat surface", () => { headerBox.y + headerBox.height ); await expect(touchPage.getByTestId("chat-pane")).toBeVisible(); + + const indicator = touchPage.getByTestId("agent-view-indicator"); + const indicatorInsets = async () => { + const trackBox = (await track.boundingBox())!; + const indicatorBox = (await indicator.boundingBox())!; + const segmentStart = + (await toggle.getAttribute("data-view")) === "console" + ? trackBox.x + trackBox.width / 2 + : trackBox.x; + const segmentEnd = segmentStart + trackBox.width / 2; + return { + left: Math.round(indicatorBox.x - segmentStart), + right: Math.round(segmentEnd - (indicatorBox.x + indicatorBox.width)), + top: Math.round(indicatorBox.y - trackBox.y), + bottom: Math.round( + trackBox.y + + trackBox.height - + (indicatorBox.y + indicatorBox.height) + ), + }; + }; + await expect.poll(indicatorInsets).toEqual({ + left: 2, + right: 2, + top: 2, + bottom: 2, + }); await touchPage.screenshot({ path: test.info().outputPath("chat-surface-touch-390.png"), }); @@ -624,8 +680,91 @@ test.describe("Chat surface", () => { await touchPage.getByTestId("agent-view-console").tap(); await expect(toggle).toHaveAttribute("data-view", "console"); await expect(touchPage.getByTestId("terminal-pane")).toBeVisible(); + await expect.poll(indicatorInsets).toEqual({ + left: 2, + right: 2, + top: 2, + bottom: 2, + }); + + await touchPage.getByTestId("agent-view-chat").tap(); + await expect(toggle).toHaveAttribute("data-view", "chat"); + await expect(touchPage.getByTestId("chat-pane")).toBeVisible(); + await expect.poll(indicatorInsets).toEqual({ + left: 2, + right: 2, + top: 2, + bottom: 2, + }); + + await touchPage.setViewportSize({ width: 320, height: 844 }); + await expect + .poll(async () => { + const toggleBox = (await toggle.boundingBox())!; + const triggerBox = (await touchPage + .getByTestId("chat-filters-trigger") + .boundingBox())!; + return { + railWidth: Math.round( + (await touchPage.getByTestId("agent-view-track").boundingBox())! + .width + ), + controlsOverlap: Math.max( + 0, + Math.round(toggleBox.x + toggleBox.width - triggerBox.x) + ), + pageOverflow: await touchPage.evaluate( + () => document.documentElement.scrollWidth - innerWidth + ), + }; + }) + .toEqual({ railWidth: 124, controlsOverlap: 0, pageOverflow: 0 }); + await touchPage.screenshot({ + path: test.info().outputPath("chat-surface-touch-long-name-320.png"), + }); } finally { await context.close(); } }); + + test("keeps wide markdown tables reachable without page overflow", async ({ + page, + request, + }) => { + await setChatSurface(request, true); + const agent = await createAgentViaAPI(request, { + name: `e2e-chat-table-${Date.now()}`, + }); + await callMcpTool(request, agent.id, "dispatch_chat_post", { + text: [ + "| Alpha heading | Bravo heading | Charlie heading | Delta heading | Echo heading |", + "| --- | --- | --- | --- | --- |", + "| alpha-value-long | bravo-value-long | charlie-value-long | delta-value-long | echo-value-long |", + ].join("\n"), + }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`/agents/${agent.id}`, { waitUntil: "domcontentloaded" }); + const scroller = page.getByTestId("markdown-table-scroll"); + await scroller.waitFor({ state: "visible" }); + await expect + .poll(() => + scroller.evaluate((node) => ({ + overflowX: getComputedStyle(node).overflowX, + scrollable: node.scrollWidth > node.clientWidth, + pageOverflow: document.documentElement.scrollWidth - innerWidth, + })) + ) + .toEqual({ overflowX: "auto", scrollable: true, pageOverflow: 0 }); + + await scroller.evaluate((node) => { + node.scrollLeft = 120; + }); + await expect + .poll(() => scroller.evaluate((node) => node.scrollLeft)) + .toBeGreaterThan(0); + await page.screenshot({ + path: test.info().outputPath("chat-surface-wide-table-390.png"), + }); + }); }); diff --git a/packages/shared/src/chat-types.ts b/packages/shared/src/chat-types.ts index f200628bc..fb6f27f3b 100644 --- a/packages/shared/src/chat-types.ts +++ b/packages/shared/src/chat-types.ts @@ -131,6 +131,8 @@ export type ChatAgentMessageEntry = { senderName: string; recipientAgentId: string; recipientName: string; + /** True when either endpoint is a direct child of this feed's agent. */ + involvesChildAgent?: boolean; content: string; /** `null` while the pane delivery is still pending (see `agent_messages`). */ delivered: boolean | null;