From dac1ebbcf9b239f987f4bc876110effe132556f6 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sun, 30 Aug 2026 15:45:09 +0800 Subject: [PATCH 1/2] Polish desktop session UX and server session lifecycle - Add session composer, fork boundary, and keep-alive session shell to the desktop client - Generate and persist server-side session titles with native/ACP protocol updates - Extend message editing and session handlers with roundtrip and persistence tests - Update conversation data model and title generation specs Co-authored-by: Devo --- .../src/v2/client-native-interactions.test.ts | 119 ++++ .../src/v2/client-session-fork.test.ts | 111 ++++ .../packages/devo-ai-sdk/src/v2/client.ts | 157 ++++- .../packages/ui/src/styles/globals.css | 34 + apps/desktop/src/main/devo-manager.ts | 17 +- .../src/main/native-stdio-client.test.ts | 2 + apps/desktop/src/main/native-stdio-client.ts | 5 + apps/desktop/src/main/tray-menu.ts | 2 +- .../src/renderer/atoms/derived/agents.ts | 15 +- .../renderer/atoms/session-composer.test.ts | 79 +++ .../src/renderer/atoms/session-composer.ts | 101 +++ apps/desktop/src/renderer/atoms/ui.ts | 52 +- .../src/renderer/components/agent-detail.tsx | 28 +- .../components/chat/chat-turn-skeleton.tsx | 35 + .../components/chat/chat-turn.test.ts | 37 +- .../renderer/components/chat/chat-turn.tsx | 47 +- .../renderer/components/chat/chat-view.tsx | 621 ++++++++++++------ .../components/chat/composer-mode-chip.tsx | 76 +++ .../components/chat/fork-boundary-divider.tsx | 57 ++ .../components/chat/fork-boundary.test.ts | 31 + .../renderer/components/chat/fork-boundary.ts | 27 + .../chat/slash-command-popover.test.ts | 32 +- .../components/chat/slash-command-popover.tsx | 16 +- .../components/chat/user-message-block.tsx | 144 ++++ .../renderer/components/command-palette.tsx | 4 +- .../components/session-keep-alive-host.tsx | 41 ++ .../src/renderer/components/session-shell.tsx | 335 ++++++++++ .../src/renderer/components/session-view.tsx | 393 +++-------- .../renderer/components/sidebar-layout.tsx | 11 +- .../sidebar/app-sidebar-content.tsx | 8 +- .../components/sidebar/sidebar-rows.tsx | 5 +- .../src/renderer/hooks/use-commands.ts | 19 +- apps/desktop/src/renderer/hooks/use-server.ts | 34 +- .../src/renderer/hooks/use-session-chat.ts | 23 +- .../renderer/hooks/use-session-controller.ts | 257 ++++++++ .../hooks/use-session-keep-alive-logic.ts | 19 + .../hooks/use-session-keep-alive.test.ts | 34 + .../renderer/hooks/use-session-keep-alive.ts | 30 + .../lib/session-scroll-restore.test.ts | 39 ++ .../renderer/lib/session-scroll-restore.ts | 119 ++++ apps/desktop/src/renderer/lib/types.ts | 6 + apps/desktop/src/renderer/services/devo.ts | 10 +- crates/client/src/client_core.rs | 22 +- crates/client/src/stdio.rs | 25 +- crates/core/src/conversation/history.rs | 2 + .../core/src/conversation/legacy_projector.rs | 36 +- crates/core/src/conversation/records.rs | 26 +- crates/core/src/conversation/rollout_v2.rs | 2 +- crates/core/src/conversation/v2_inverse.rs | 27 +- crates/core/tests/legacy_projector.rs | 2 + crates/core/tests/v2_roundtrip.rs | 5 + crates/protocol/src/acp/common.rs | 8 + crates/protocol/src/acp/mod.rs | 4 + crates/protocol/src/acp/session.rs | 2 + crates/protocol/src/acp/ts.rs | 15 +- crates/protocol/src/conversation.rs | 10 +- crates/protocol/src/native/rpc_session.rs | 18 +- crates/protocol/src/native/session.rs | 25 +- crates/protocol/src/native/wire_projector.rs | 46 +- crates/protocol/src/session.rs | 9 + crates/server/AGENTS.md | 4 + crates/server/src/db.rs | 76 ++- crates/server/src/persistence.rs | 45 ++ crates/server/src/projection.rs | 2 + crates/server/src/runtime.rs | 1 + crates/server/src/runtime/agents.rs | 2 + crates/server/src/runtime/connection.rs | 186 +++++- crates/server/src/runtime/goal_handlers.rs | 7 +- crates/server/src/runtime/handlers.rs | 1 + .../src/runtime/handlers/acp/session.rs | 37 +- crates/server/src/runtime/handlers/history.rs | 2 +- .../src/runtime/handlers/message_edit.rs | 103 ++- crates/server/src/runtime/handlers/session.rs | 193 ++++-- .../src/runtime/handlers/session_fork.rs | 170 +++++ crates/server/src/runtime/handlers/turn.rs | 10 +- crates/server/src/runtime/items.rs | 275 -------- .../src/runtime/session_actor/registry.rs | 1 + crates/server/src/runtime/session_title.rs | 325 +++++++++ crates/server/src/runtime/turn_exec/mod.rs | 2 +- crates/server/src/titles.rs | 98 --- crates/server/tests/acp_session_delete.rs | 36 +- crates/server/tests/goal_title_generation.rs | 13 +- crates/server/tests/persistence_resume.rs | 47 +- crates/server/tests/protocol_contract.rs | 8 + .../server/tests/session_fork_persistence.rs | 88 ++- crates/server/tests/turn_start_persistence.rs | 163 ++++- crates/tui/src/app_command.rs | 17 +- crates/tui/src/chatwidget/selection.rs | 24 + crates/tui/src/interactive.rs | 7 +- crates/tui/src/worker.rs | 35 +- crates/tui/src/worker/session_restore.rs | 1 - crates/tui/src/worker/subagent_events.rs | 8 +- ...-REQ-CONV-005-immediate-message-editing.md | 4 +- .../L2-DES-APP-003-client-server-protocol.md | 2 +- ...2-DES-CONV-001-session-jsonl-data-model.md | 2 +- .../server/L2-DES-SERVER-title-generation.md | 71 ++ 96 files changed, 4301 insertions(+), 1281 deletions(-) create mode 100644 apps/desktop/packages/devo-ai-sdk/src/v2/client-session-fork.test.ts create mode 100644 apps/desktop/src/renderer/atoms/session-composer.test.ts create mode 100644 apps/desktop/src/renderer/atoms/session-composer.ts create mode 100644 apps/desktop/src/renderer/components/chat/chat-turn-skeleton.tsx create mode 100644 apps/desktop/src/renderer/components/chat/composer-mode-chip.tsx create mode 100644 apps/desktop/src/renderer/components/chat/fork-boundary-divider.tsx create mode 100644 apps/desktop/src/renderer/components/chat/fork-boundary.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/fork-boundary.ts create mode 100644 apps/desktop/src/renderer/components/chat/user-message-block.tsx create mode 100644 apps/desktop/src/renderer/components/session-keep-alive-host.tsx create mode 100644 apps/desktop/src/renderer/components/session-shell.tsx create mode 100644 apps/desktop/src/renderer/hooks/use-session-controller.ts create mode 100644 apps/desktop/src/renderer/hooks/use-session-keep-alive-logic.ts create mode 100644 apps/desktop/src/renderer/hooks/use-session-keep-alive.test.ts create mode 100644 apps/desktop/src/renderer/hooks/use-session-keep-alive.ts create mode 100644 apps/desktop/src/renderer/lib/session-scroll-restore.test.ts create mode 100644 apps/desktop/src/renderer/lib/session-scroll-restore.ts create mode 100644 crates/server/src/runtime/handlers/session_fork.rs create mode 100644 crates/server/src/runtime/session_title.rs create mode 100644 specs/L2/server/L2-DES-SERVER-title-generation.md diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts index 72c9a357..192b5420 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts @@ -47,6 +47,8 @@ class FakeNativeTransport implements DevoNativeTransport { return { views: [nativeWorkspaceView] } case "turn/start": return { turn: nativeTurnInProgress } + case "session/message/edit": + return editedMessageResult case "session/resume": return { session: nativeSession, @@ -155,6 +157,26 @@ const nativeTurnCompleted = { completedAt: "2026-08-24T00:00:08Z", } +const editedMessageResult = { + editState: "accepted", + replacementTurnId: "turn-2", + item: { + id: "item-user-edited", + sessionId: nativeSession.id, + turnId: "turn-2", + revision: 2, + seq: 1, + state: "completed", + createdAt: "2026-08-24T00:00:10.000Z", + updatedAt: "2026-08-24T00:00:10.000Z", + item: { + type: "userMessage", + content: [{ type: "text", text: "edited" }], + entry: "turnStart", + }, + }, +} + const nativeOccupancy = { totalTokens: 100_000, contextWindowTokens: 200_000, @@ -1197,4 +1219,101 @@ describe("Native desktop SDK interactions", () => { expect(after?.time.lastActivity).toBe(Date.parse("2026-08-24T00:00:08Z")) expect(after?.time.updated).toBe(Date.parse("2026-08-24T00:00:08Z")) }) + + test("session/message/edit sends canonical params", async () => { + const transport = new FakeNativeTransport() + const client = createDevoClient({ directory: "/repo", transport }) + await client.session.create() + await client.session.editMessage({ + sessionID: nativeSession.id, + itemID: "item-user-1", + text: "edited", + }) + const request = transport.requests.find((entry) => entry.method === "session/message/edit") + const params = (request?.params ?? {}) as { + sessionId?: string + itemId?: string + expectedRevision?: number + content?: unknown + idempotencyKey?: string + } + expect({ + method: request?.method, + sessionId: params.sessionId, + itemId: params.itemId, + expectedRevision: params.expectedRevision, + content: params.content, + hasIdempotencyKey: typeof params.idempotencyKey === "string" && params.idempotencyKey.length > 0, + }).toEqual({ + method: "session/message/edit", + sessionId: nativeSession.id, + itemId: "item-user-1", + expectedRevision: 0, + content: [{ type: "text", text: "edited" }], + hasIdempotencyKey: true, + }) + }) + + test("turn/superseded removes messages from the replaced turn", async () => { + const transport = new FakeNativeTransport() + transport.sessionItems = [ + { + id: "item-user-1", + sessionId: nativeSession.id, + turnId: "turn-1", + seq: 1, + revision: 1, + createdAt: "2026-08-24T00:00:00.000Z", + updatedAt: "2026-08-24T00:00:00.000Z", + state: "completed", + item: { + type: "userMessage", + content: [{ type: "text", text: "hello" }], + entry: "turnStart", + }, + }, + { + id: "item-assistant-1", + sessionId: nativeSession.id, + turnId: "turn-1", + seq: 2, + revision: 1, + createdAt: "2026-08-24T00:00:02.000Z", + updatedAt: "2026-08-24T00:00:14.000Z", + state: "completed", + item: { + type: "assistantMessage", + text: "world", + }, + }, + ] + const client = createDevoClient({ directory: "/repo", transport }) + const loaded = await client.session.messages({ sessionID: nativeSession.id }) + expect(loaded.data.map((entry) => entry.info.id).sort()).toEqual([ + "item-assistant-1", + "item-user-1", + ]) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + transport.emit({ + type: "notification", + method: "turn/superseded", + params: { + sessionId: nativeSession.id, + supersededTurnId: "turn-1", + replacementTurnId: "turn-2", + editId: "edit-1", + reason: "message_edit_previous", + }, + }) + expect(await nextPayloadOfType(stream, "message.removed")).toEqual({ + type: "message.removed", + properties: { sessionID: nativeSession.id, messageID: "item-user-1" }, + }) + expect(await nextPayloadOfType(stream, "message.removed")).toEqual({ + type: "message.removed", + properties: { sessionID: nativeSession.id, messageID: "item-assistant-1" }, + }) + const remaining = await client.session.messages({ sessionID: nativeSession.id }) + expect(remaining.data).toEqual([]) + }) }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-fork.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-fork.test.ts new file mode 100644 index 00000000..6e1dcedb --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-fork.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test" +import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEvent } from "./client" + +class FakeTransport implements DevoNativeTransport { + readonly requests: Array<{ method: string; params: unknown }> = [] + private listeners: Array<(event: DevoNativeTransportEvent) => void> = [] + + constructor(private readonly handler: (method: string, params: unknown) => unknown) {} + + async request(method: string, params?: unknown): Promise { + this.requests.push({ method, params }) + return this.handler(method, params) + } + + async respond(): Promise {} + + subscribe(listener: (event: DevoNativeTransportEvent) => void): () => void { + this.listeners.push(listener) + return () => { + this.listeners = this.listeners.filter((item) => item !== listener) + } + } + + connected(): boolean { + return true + } +} + +const forkedSession = { + id: "child-session", + version: 1, + cwd: "/repo", + title: "Forked", + parent: null, + forkFromId: "parent-session", + atTurnId: "turn-2", + createdAt: "2026-01-01T00:00:00.000Z", + lastActivityAt: "2026-01-01T00:00:00.000Z", + status: "idle", + flags: [], + archived: false, + ephemeral: false, + model: { provider: "test", model: "test-model" }, + settings: { permissionProfile: "default" }, + preview: "", + queuedCount: 0, + usage: { + total: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + callCount: 0, + meteredCallCount: 0, + failedCallCount: 0, + cancelledCallCount: 0, + }, + byPurpose: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }, +} + +describe("session.fork", () => { + test("sends canonical session/fork params and remembers the child", async () => { + const transport = new FakeTransport((method, params) => { + if (method === "initialize") { + return { + protocolVersion: 1, + agentCapabilities: {}, + authMethods: [], + } + } + if (method === "session/fork") { + expect(params).toEqual({ + sessionId: "parent-session", + atTurnId: "turn-2", + cut: "before", + }) + return { session: forkedSession } + } + if (method === "session/resume") { + return { session: forkedSession } + } + if (method === "session/items/list") { + return { data: [], nextCursor: null } + } + if (method === "subscription/create") { + return { subscriptionId: "sub-1", cursors: [] } + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ + directory: "/repo", + transport, + }) + + const result = await client.session.fork({ + sessionID: "parent-session", + atTurnId: "turn-2", + cut: "before", + }) + + expect(result.data.id).toBe("child-session") + expect(result.data.forkFromId).toBe("parent-session") + expect(result.data.parentID).toBeUndefined() + expect(transport.requests.some((request) => request.method === "session/fork")).toBe(true) + }) +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index acfead80..9d2f1a8b 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -252,6 +252,10 @@ function partCacheKey(sessionId: string, messageId: string): string { return `${sessionId}\u001f${messageId}` } +function renderedNativeItemKey(sessionId: string, itemId: string): string { + return partCacheKey(sessionId, itemId) +} + function objectRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? (value as Record) : undefined } @@ -924,6 +928,15 @@ function parseTimestampMs(value: unknown): number | undefined { return Number.isFinite(parsed) ? parsed : undefined } +function parseTitleState(value: unknown): string | undefined { + if (typeof value === "string") return value + if (value && typeof value === "object") { + const keys = Object.keys(value as Record) + if (keys.length === 1) return keys[0] + } + return undefined +} + /** Wire timestamps from a native ItemEnvelope into appendText/appendTool updates. */ function nativeItemTimingFields( envelope: Record, @@ -1207,6 +1220,33 @@ class NativeClient { }) } }, + editMessage: async (params: { sessionID: string; itemID: string; text: string }) => { + const directory = this.sessionDirectories.get(params.sessionID) ?? this.options.directory ?? defaultCwd() + const promptStartedAt = Math.max(Date.now(), this.lastEventTime + 1) + this.promptStartedAtBySession.set(params.sessionID, promptStartedAt) + this.touchNativeSessionActivity(params.sessionID, promptStartedAt) + try { + const result = await this.requestCanonical("session/message/edit", { + sessionId: params.sessionID, + itemId: params.itemID, + expectedRevision: 0, + content: [{ type: "text", text: params.text }], + idempotencyKey: crypto.randomUUID(), + }) + if (this.sessionStatuses.get(params.sessionID)?.type !== "busy") { + const busyStatus = { type: "busy" } + this.sessionStatuses.set(params.sessionID, busyStatus) + this.emit(directory, { + type: "session.status", + properties: { sessionID: params.sessionID, status: busyStatus }, + }) + } + return { data: result } + } catch (error) { + this.promptStartedAtBySession.delete(params.sessionID) + throw error + } + }, abort: async (params: { sessionID: string }) => { const interruptParams: SessionInterruptParams = { scope: { scope: "session", sessionId: params.sessionID }, @@ -1277,9 +1317,30 @@ class NativeClient { messages: async (params: { sessionID: string; limit?: number }) => ({ data: await this.sessionMessages(params.sessionID, normalizedHistoryLimit(params.limit)), }), - fork: async (params: { sessionID: string }) => ({ - data: this.sessions.get(params.sessionID), - }), + fork: async (params: { + sessionID: string + atTurnId?: string + cut?: "through" | "before" + }) => { + await this.ensureInitialized() + const result = (await this.requestCanonical("session/fork", { + sessionId: params.sessionID, + ...(params.atTurnId ? { atTurnId: params.atTurnId } : {}), + ...(params.cut ? { cut: params.cut } : {}), + })) as { session?: Record } + const sessionValue = result.session + if (!sessionValue) { + throw new Error("session/fork returned no session") + } + const session = this.rememberNativeSession(sessionValue) + await this.ensureSessionSubscription(session.id) + await this.loadSession(session.id) + this.emit(session.directory ?? this.options.directory ?? defaultCwd(), { + type: "session.created", + properties: { info: session, session }, + }) + return { data: session } + }, } turn = { @@ -1305,6 +1366,34 @@ class NativeClient { }, } + task = { + startAgent: async (params: { + sessionID: string + prompt: string + forkTurns?: string + maxTurns?: number + toolPolicy?: "inherit" | "deny_all" + ephemeral?: boolean + }) => { + await this.ensureInitialized() + const result = (await this.requestCanonical("task/start", { + kind: "agent", + sessionId: params.sessionID, + input: [{ type: "text", text: params.prompt }], + ...(params.forkTurns ? { forkTurns: params.forkTurns } : {}), + ...(params.maxTurns !== undefined ? { maxTurns: params.maxTurns } : {}), + ...(params.toolPolicy ? { toolPolicy: params.toolPolicy } : {}), + ephemeral: params.ephemeral ?? false, + idempotencyKey: crypto.randomUUID(), + })) as { itemId?: string; item_id?: string } + return { + data: { + itemId: String(result.itemId ?? result.item_id ?? ""), + }, + } + }, + } + question = { reply: async (params: { requestID: string; answers: QuestionAnswer[] }) => { await this.respondToQuestion(params.requestID, params.answers, "question.replied") @@ -1729,7 +1818,7 @@ class NativeClient { const lastActivity = parsedLastActivity ?? existing?.time.lastActivity ?? created const session: Session = { id: info.sessionId, - title: info.title ?? existing?.title ?? "New session", + title: info.title ?? existing?.title, parentID: meta?.parent_session_id ?? existing?.parentID ?? undefined, time: { created, updated, lastActivity }, directory: info.cwd, @@ -1762,10 +1851,26 @@ class NativeClient { const created = parseTimestampMs(info.createdAt) ?? existing?.time.created ?? Date.now() const updated = parseTimestampMs(info.lastActivityAt) ?? existing?.time.updated ?? created const parent = objectRecord(info.parent) + const forkFromId = + typeof info.forkFromId === "string" + ? info.forkFromId + : typeof info.fork_from_id === "string" + ? info.fork_from_id + : existing?.forkFromId + const atTurnId = + typeof info.atTurnId === "string" + ? info.atTurnId + : typeof info.fork_at_turn_id === "string" + ? info.fork_at_turn_id + : existing?.atTurnId + const titleState = parseTitleState(info.titleState ?? info.title_state) const session: Session = { id, - title: typeof info.title === "string" ? info.title : existing?.title ?? "New session", + title: typeof info.title === "string" ? info.title : existing?.title, + titleState: titleState ?? existing?.titleState ?? "Unset", parentID: typeof parent?.sessionId === "string" ? parent.sessionId : existing?.parentID, + forkFromId, + atTurnId, time: { created, updated, lastActivity: updated }, directory: String(info.cwd ?? existing?.directory ?? this.options.directory ?? defaultCwd()), totalInputTokens: Number(total?.inputTokens ?? existing?.totalInputTokens ?? 0), @@ -2181,6 +2286,12 @@ class NativeClient { this.handleWorkspaceChangesUpdated(payload as WorkspaceChangesUpdatedPayload) return true } + if (method === "turn/superseded") { + const sessionId = String(value.sessionId ?? "") + const supersededTurnId = String(value.supersededTurnId ?? "") + if (sessionId && supersededTurnId) this.removeMessagesForTurn(sessionId, supersededTurnId) + return true + } return false } @@ -2209,15 +2320,16 @@ class NativeClient { status, turnId: String(envelope.turnId ?? ""), }) - if (completed) this.renderedNativeItems.add(id) + if (completed) this.renderedNativeItems.add(renderedNativeItemKey(sessionId, id)) return } if (itemType === "plan") { this.upsertPlan(sessionId, directory, id, item, String(envelope.turnId ?? "")) - if (completed) this.renderedNativeItems.add(id) + if (completed) this.renderedNativeItems.add(renderedNativeItemKey(sessionId, id)) return } - if (completed && this.renderedNativeItems.has(id)) { + const renderedKey = renderedNativeItemKey(sessionId, id) + if (completed && this.renderedNativeItems.has(renderedKey)) { // History dual-writes ToolResult then FileChange under the same item id. // Allow a richer FileChange (or file-shaped ToolResult) to upgrade the // nameless generic tool that won the first pass. @@ -2353,7 +2465,7 @@ class NativeClient { ...nativeItemTimingFields(envelope, { includeCompletedAt: completed }), }) } - if (completed) this.renderedNativeItems.add(id) + if (completed) this.renderedNativeItems.add(renderedNativeItemKey(sessionId, id)) } private finalizeNativeAssistantItem( @@ -2813,6 +2925,33 @@ class NativeClient { return { directory, known } } + private removeMessagesForTurn(sessionId: string, turnId: string): void { + const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() + const messages = this.messages.get(sessionId) + if (!messages) return + const remaining: Message[] = [] + for (const message of messages) { + const key = partCacheKey(sessionId, message.id) + const messageTurnId = this.messageTurnIds.get(key) ?? message.turnID + if (messageTurnId !== turnId) { + remaining.push(message) + continue + } + this.parts.delete(key) + this.messageTurnIds.delete(key) + this.renderedNativeItems.delete(renderedNativeItemKey(sessionId, message.id)) + if (this.lastUserMessageBySession.get(sessionId) === message.id) { + this.lastUserMessageBySession.delete(sessionId) + } + this.emit(directory, { + type: "message.removed", + properties: { sessionID: sessionId, messageID: message.id }, + }) + } + this.messages.set(sessionId, remaining) + this.userMessageByTurn.delete(this.turnKey(sessionId, turnId)) + } + private emitSessionDeleted(sessionId: string, directory: string): void { this.emit(directory, { type: "session.deleted", diff --git a/apps/desktop/packages/ui/src/styles/globals.css b/apps/desktop/packages/ui/src/styles/globals.css index eae9d129..d29a1b29 100644 --- a/apps/desktop/packages/ui/src/styles/globals.css +++ b/apps/desktop/packages/ui/src/styles/globals.css @@ -809,6 +809,40 @@ background-color: var(--ring); } + /* Chat transcript — hover-only scrollbar with stable gutter */ + .scrollbar-chat { + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: scrollbar-color var(--duration-fast, 0.15s); + } + .scrollbar-chat:hover { + scrollbar-color: var(--border) transparent; + } + .scrollbar-chat:active { + scrollbar-color: var(--ring) transparent; + } + .scrollbar-chat::-webkit-scrollbar { + width: 8px; + height: 8px; + } + .scrollbar-chat::-webkit-scrollbar-track { + background: transparent; + } + .scrollbar-chat::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: 9999px; + border: 2px solid transparent; + background-clip: content-box; + transition: background-color var(--duration-fast, 0.15s); + } + .scrollbar-chat:hover::-webkit-scrollbar-thumb { + background-color: var(--border); + } + .scrollbar-chat:hover::-webkit-scrollbar-thumb:hover { + background-color: var(--ring); + } + /* Comfort scrollbar — larger hit target for primary navigation panes */ .scrollbar-comfort { scrollbar-width: auto; diff --git a/apps/desktop/src/main/devo-manager.ts b/apps/desktop/src/main/devo-manager.ts index 7a6cca8d..90ea3bec 100644 --- a/apps/desktop/src/main/devo-manager.ts +++ b/apps/desktop/src/main/devo-manager.ts @@ -199,5 +199,20 @@ function notifyServerReady(): void { } async function initialize(client: StdioNativeClient): Promise { - await client.request("initialize", DESKTOP_INITIALIZE_PARAMS) + try { + await client.request("initialize", DESKTOP_INITIALIZE_PARAMS) + } catch (error) { + // A timed-out initialize can still leave a warming stdio child alive; retry once + // before surfacing the error to the renderer (common when opening a session cold). + if (client.connected() && isInitializeTimeoutError(error)) { + log.warn("initialize timed out during cold start; retrying once") + await client.request("initialize", DESKTOP_INITIALIZE_PARAMS) + return + } + throw error + } +} + +function isInitializeTimeoutError(error: unknown): boolean { + return error instanceof Error && error.message.startsWith("initialize request ") } diff --git a/apps/desktop/src/main/native-stdio-client.test.ts b/apps/desktop/src/main/native-stdio-client.test.ts index b7033f90..34e11765 100644 --- a/apps/desktop/src/main/native-stdio-client.test.ts +++ b/apps/desktop/src/main/native-stdio-client.test.ts @@ -179,11 +179,13 @@ describe("StdioNativeClient", () => { test("gives MCP admin RPCs a longer timeout than ordinary requests", () => { expect({ + initialize: requestTimeoutMsForMethod("initialize", 10_000), sessionList: requestTimeoutMsForMethod("session/list", 10_000), mcpTools: requestTimeoutMsForMethod("mcp/tools", 10_000), mcpSetEnabled: requestTimeoutMsForMethod("mcp/set_enabled", 5), providerValidate: requestTimeoutMsForMethod("provider/validate", 10_000), }).toEqual({ + initialize: 60_000, sessionList: 10_000, mcpTools: 60_000, mcpSetEnabled: 60_000, diff --git a/apps/desktop/src/main/native-stdio-client.ts b/apps/desktop/src/main/native-stdio-client.ts index 116434b5..a0e730db 100644 --- a/apps/desktop/src/main/native-stdio-client.ts +++ b/apps/desktop/src/main/native-stdio-client.ts @@ -33,10 +33,15 @@ type PendingRequest = { } const REQUEST_TIMEOUT_MS = 10_000 +/** Cold-starting the managed `devo server` process can exceed the default RPC budget. */ +export const INITIALIZE_REQUEST_TIMEOUT_MS = 60_000 /** MCP admin RPCs may start a lazy server before listing tools. */ export const MCP_ADMIN_REQUEST_TIMEOUT_MS = 60_000 export function requestTimeoutMsForMethod(method: string, fallbackMs: number): number | undefined { + if (method === "initialize") { + return Math.max(fallbackMs, INITIALIZE_REQUEST_TIMEOUT_MS) + } if (method === "provider/validate") { return undefined } diff --git a/apps/desktop/src/main/tray-menu.ts b/apps/desktop/src/main/tray-menu.ts index 1edd1c13..2d07f5e7 100644 --- a/apps/desktop/src/main/tray-menu.ts +++ b/apps/desktop/src/main/tray-menu.ts @@ -251,7 +251,7 @@ function numericSessionField(session: Session, field: string): number { } function titleForSession(title: unknown): string { - return typeof title === "string" && title.trim() ? title : "New chat" + return typeof title === "string" && title.trim() ? title : "New Chat" } function truncateTitle(title: string): string { diff --git a/apps/desktop/src/renderer/atoms/derived/agents.ts b/apps/desktop/src/renderer/atoms/derived/agents.ts index 31a600d4..fd9d2bef 100644 --- a/apps/desktop/src/renderer/atoms/derived/agents.ts +++ b/apps/desktop/src/renderer/atoms/derived/agents.ts @@ -41,11 +41,14 @@ function agentEqual(prev: Agent | null, next: Agent | null): boolean { // so status changes from descendant sub-agents propagate to the sidebar. prev.currentActivity === next.currentActivity && prev.parentId === next.parentId && + prev.forkFromId === next.forkFromId && + prev.atTurnId === next.atTurnId && prev.worktreePath === next.worktreePath && prev.worktreeBranch === next.worktreeBranch && prev.createdAt === next.createdAt && prev.lastActiveAt === next.lastActiveAt && prev.hasUnreadCompletion === next.hasUnreadCompletion && + prev.titleGenerating === next.titleGenerating && prev.permissions.length === next.permissions.length && prev.questions.length === next.questions.length && prev.permissions[0] === next.permissions[0] && @@ -379,10 +382,16 @@ export const agentFamily = atomFamily((sessionId: string) => { const effectivePerm = get(effectivePermissionFamily(session.id)) const effectiveQ = get(effectiveQuestionFamily(session.id)) + const titleState = + typeof (session as { titleState?: string }).titleState === "string" + ? (session as { titleState?: string }).titleState + : undefined + const next: Agent = { id: session.id, sessionId: session.id, - name: session.title || "Untitled", + name: session.title || "New Chat", + titleGenerating: titleState === "Generating" && !session.title, status: agentStatus, environment: "local" as const, project: projectName, @@ -402,6 +411,8 @@ export const agentFamily = atomFamily((sessionId: string) => { permissions, questions, parentId: session.parentID, + forkFromId: session.forkFromId, + atTurnId: session.atTurnId, worktreePath: entry.worktreePath, worktreeBranch: entry.worktreeBranch, createdAt: created, @@ -426,7 +437,7 @@ export const sessionNameFamily = atomFamily((sessionId: string) => atom((get) => { const entry = get(sessionFamily(sessionId)) if (!entry) return undefined - return entry.session.title || "Untitled" + return entry.session.title || "New Chat" }), ) diff --git a/apps/desktop/src/renderer/atoms/session-composer.test.ts b/apps/desktop/src/renderer/atoms/session-composer.test.ts new file mode 100644 index 00000000..b4d720c8 --- /dev/null +++ b/apps/desktop/src/renderer/atoms/session-composer.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" +import { + composerFromMessages, + composerFromPersistedModel, + EMPTY_COMPOSER_STATE, + hydrateSessionComposerState, + type SessionComposerState, +} from "./session-composer" +import type { Message } from "../lib/types" + +describe("hydrateSessionComposerState", () => { + test("prefers last user message model over project default", () => { + const messages: Message[] = [ + { + id: "m1", + role: "user", + content: "hello", + model: { providerID: "openai", modelID: "gpt-4" }, + variant: "high", + } as Message, + ] + const hydrated = hydrateSessionComposerState(EMPTY_COMPOSER_STATE, messages, { + providerID: "anthropic", + modelID: "claude-3", + }) + expect(hydrated).toEqual({ + model: { providerID: "openai", modelID: "gpt-4" }, + variant: "high", + agent: null, + hasUserOverride: false, + }) + }) + + test("falls back to project default for empty sessions", () => { + const hydrated = hydrateSessionComposerState(EMPTY_COMPOSER_STATE, [], { + providerID: "anthropic", + modelID: "claude-3", + variant: "medium", + agent: "build", + }) + expect(hydrated).toEqual({ + model: { providerID: "anthropic", modelID: "claude-3" }, + variant: "medium", + agent: "build", + hasUserOverride: false, + }) + }) + + test("does not overwrite user overrides", () => { + const current: SessionComposerState = { + model: { providerID: "openai", modelID: "gpt-4o" }, + variant: undefined, + agent: null, + hasUserOverride: true, + } + const messages: Message[] = [ + { + id: "m1", + role: "user", + content: "hello", + model: { providerID: "openai", modelID: "gpt-4" }, + } as Message, + ] + expect(hydrateSessionComposerState(current, messages, undefined)).toEqual(current) + }) +}) + +describe("composerFromMessages", () => { + test("returns null when no user messages carry composer metadata", () => { + const messages: Message[] = [{ id: "m1", role: "assistant", content: "hi" } as Message] + expect(composerFromMessages(messages)).toBeNull() + }) +}) + +describe("composerFromPersistedModel", () => { + test("returns empty state when project default is missing", () => { + expect(composerFromPersistedModel(undefined)).toEqual(EMPTY_COMPOSER_STATE) + }) +}) diff --git a/apps/desktop/src/renderer/atoms/session-composer.ts b/apps/desktop/src/renderer/atoms/session-composer.ts new file mode 100644 index 00000000..c52e55ac --- /dev/null +++ b/apps/desktop/src/renderer/atoms/session-composer.ts @@ -0,0 +1,101 @@ +import { atom } from "jotai" +import { atomFamily } from "jotai/utils" +import type { ModelRef } from "../hooks/use-devo-data" +import type { Message } from "../lib/types" +import type { PersistedModelRef } from "./preferences" + +export interface SessionComposerState { + model: ModelRef | null + variant?: string + agent: string | null + /** Set when the user explicitly changes composer settings for this session. */ + hasUserOverride: boolean +} + +const EMPTY_COMPOSER_STATE: SessionComposerState = { + model: null, + variant: undefined, + agent: null, + hasUserOverride: false, +} + +export { EMPTY_COMPOSER_STATE } + +export const sessionComposerFamily = atomFamily((_sessionId: string) => + atom(EMPTY_COMPOSER_STATE), +) + +export function composerFromPersistedModel( + stored: PersistedModelRef | undefined, +): SessionComposerState { + if (!stored?.providerID || !stored?.modelID) { + return EMPTY_COMPOSER_STATE + } + return { + model: { providerID: stored.providerID, modelID: stored.modelID }, + variant: stored.variant, + agent: stored.agent ?? null, + hasUserOverride: false, + } +} + +export function composerFromMessages(messages: Message[]): SessionComposerState | null { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] + if (message.role !== "user") continue + const dynamic = message as Message & Record + let model: ModelRef | null = null + if ("model" in message && message.model) { + const raw = message.model as { providerID: string; modelID: string } + if (raw.providerID && raw.modelID) { + model = { providerID: raw.providerID, modelID: raw.modelID } + } + } + const variant = + typeof dynamic.variant === "string" && dynamic.variant.length > 0 + ? dynamic.variant + : undefined + const agentName = + typeof dynamic.agent === "string" && dynamic.agent.length > 0 ? dynamic.agent : null + if (model || variant || agentName) { + return { + model, + variant, + agent: agentName, + hasUserOverride: false, + } + } + } + return null +} + +export function hydrateSessionComposerState( + current: SessionComposerState, + messages: Message[], + projectDefault: PersistedModelRef | undefined, +): SessionComposerState { + if (current.hasUserOverride) return current + const fromMessages = composerFromMessages(messages) + if (fromMessages) return fromMessages + if (messages.length > 0) return current + return composerFromPersistedModel(projectDefault) +} + +export const setSessionComposerAtom = atom( + null, + ( + _get, + set, + args: { + sessionId: string + patch: Partial + userOverride?: boolean + }, + ) => { + set(sessionComposerFamily(args.sessionId), (current) => ({ + ...current, + ...args.patch, + hasUserOverride: args.userOverride ? true : current.hasUserOverride, + })) + }, +) diff --git a/apps/desktop/src/renderer/atoms/ui.ts b/apps/desktop/src/renderer/atoms/ui.ts index 2318c9fc..f46ba2e7 100644 --- a/apps/desktop/src/renderer/atoms/ui.ts +++ b/apps/desktop/src/renderer/atoms/ui.ts @@ -31,8 +31,56 @@ export const settingsOverlayOpenAtom = atom(false) /** Whether Customize is showing in the main content pane (does not change the route). */ export const customizeOpenAtom = atom(false) -/** Last known scrollTop for a session's chat view (used when returning from Settings). */ -export const sessionScrollTopFamily = atomFamily((_sessionId: string) => atom(null)) +export interface SessionScrollSnapshot { + scrollTop: number + atBottom: boolean + /** Distinguishes an unvisited session from a deliberate scrollTop of 0. */ + hasSnapshot: boolean +} + +const EMPTY_SCROLL_SNAPSHOT: SessionScrollSnapshot = { + scrollTop: 0, + atBottom: true, + hasSnapshot: false, +} + +/** Last known scroll snapshot for a session's chat view. */ +export const sessionScrollSnapshotFamily = atomFamily((_sessionId: string) => + atom(EMPTY_SCROLL_SNAPSHOT), +) + +/** @deprecated Use sessionScrollSnapshotFamily */ +export const sessionScrollTopFamily = atomFamily((sessionId: string) => + atom( + (get) => { + const snapshot = get(sessionScrollSnapshotFamily(sessionId)) + return snapshot.hasSnapshot ? snapshot.scrollTop : null + }, + (get, set, scrollTop: number | null) => { + if (scrollTop == null) { + set(sessionScrollSnapshotFamily(sessionId), EMPTY_SCROLL_SNAPSHOT) + return + } + const current = get(sessionScrollSnapshotFamily(sessionId)) + set(sessionScrollSnapshotFamily(sessionId), { + ...current, + scrollTop, + hasSnapshot: true, + }) + }, + ), +) + +/** @deprecated Use sessionScrollSnapshotFamily */ +export const sessionAtBottomFamily = atomFamily((sessionId: string) => + atom( + (get) => get(sessionScrollSnapshotFamily(sessionId)).atBottom, + (get, set, atBottom: boolean) => { + const current = get(sessionScrollSnapshotFamily(sessionId)) + set(sessionScrollSnapshotFamily(sessionId), { ...current, atBottom }) + }, + ), +) // ============================================================ // Review Panel State diff --git a/apps/desktop/src/renderer/components/agent-detail.tsx b/apps/desktop/src/renderer/components/agent-detail.tsx index 8589a8ae..8d32efaa 100644 --- a/apps/desktop/src/renderer/components/agent-detail.tsx +++ b/apps/desktop/src/renderer/components/agent-detail.tsx @@ -77,6 +77,8 @@ interface AgentDetailProps { /** Structured chat turns (for Chat tab) */ chatTurns: ChatTurn[] chatLoading?: boolean + /** True when the initial fetch is in flight and no cached turns exist yet. */ + chatShowLoading?: boolean /** Whether earlier messages are currently being loaded */ chatLoadingEarlier?: boolean /** Whether there are earlier messages that can be loaded */ @@ -120,10 +122,10 @@ interface AgentDetailProps { onRedo?: () => Promise /** Whether the session is in a reverted state */ isReverted?: boolean - /** Revert to a specific message (for per-turn undo) */ - onRevertToMessage?: (messageId: string) => Promise - /** Fork from a turn boundary (messageId of the next turn's user message, or undefined for full fork) */ - onForkFromTurn?: (messageId?: string) => Promise + /** Fork from a turn boundary (protocol turn id, or undefined for tip fork) */ + onForkFromTurn?: (turnId?: string) => Promise + /** Edit and resend the latest user message */ + onEditUserMessage?: (messageId: string, text: string) => Promise /** Delete a specific part from a message (for error recovery) */ onDeletePart?: (sessionId: string, messageId: string, partId: string) => Promise } @@ -132,6 +134,7 @@ export function AgentDetail({ agent, chatTurns, chatLoading, + chatShowLoading, onStop, onApprove, onDeny, @@ -153,8 +156,8 @@ export function AgentDetail({ onUndo, onRedo, isReverted, - onRevertToMessage, onForkFromTurn, + onEditUserMessage, onDeletePart, }: AgentDetailProps) { const navigate = useNavigate() @@ -240,16 +243,15 @@ export function AgentDetail({ onToggleReviewPanel={() => setReviewPanelOpen((prev) => !prev)} /> - {/* Sub-agent breadcrumb -- navigate back to parent */} - {agent.parentId && ( + {/* Sub-agent breadcrumb — navigate back to parent (forks use the in-transcript marker) */} + {agent.parentId && !agent.forkFromId && ( - {label} - - -
{description}
- {isPlan &&
Shift + Tab to toggle
} -
- - ) -} - /** - * Instant-scroll when session content finishes loading. - * - * The `` (StickToBottom) uses `initial="instant"` for the first - * paint, but messages are fetched async — by the time they arrive and render, - * the library treats the content growth as a *resize* and applies - * `resize="smooth"`, causing a visible scroll animation from top → bottom. - * - * This component sits inside `` so it can access the - * StickToBottom context. It watches for the loading→loaded transition - * and forces an instant scroll-to-bottom. + * Restores scroll position when session content finishes loading or the session + * remounts after LRU eviction. Respects saved scrollTop unless the user was at bottom. */ -function ScrollOnLoad({ loading, sessionId }: { loading: boolean; sessionId: string }) { - const { scrollToBottom } = useStickToBottomContext() +function ScrollOnLoad({ + loading, + sessionId, + isActive, +}: { + loading: boolean + sessionId: string + isActive: boolean +}) { + const { scrollToBottom, scrollRef, stopScroll } = useStickToBottomContext() const settingsOverlayOpen = useAtomValue(settingsOverlayOpenAtom) const prevLoadingRef = useRef(loading) - const prevSessionRef = useRef(sessionId) + const prevActiveRef = useRef(isActive) useLayoutEffect(() => { const wasLoading = prevLoadingRef.current - const sessionChanged = prevSessionRef.current !== sessionId + const becameActive = !prevActiveRef.current && isActive prevLoadingRef.current = loading - prevSessionRef.current = sessionId + prevActiveRef.current = isActive - if (settingsOverlayOpen || getPendingRestoreScrollTop() != null) return + if (!isActive || settingsOverlayOpen || getPendingRestoreScrollTop() != null) return - // Instant scroll when: loading just finished, or session changed while not loading - // (e.g. messages were already cached in the Jotai store) - if ((wasLoading && !loading) || (sessionChanged && !loading)) { - scrollToBottom("instant") + if ((wasLoading && !loading) || becameActive) { + const snapshot = appStore.get(sessionScrollSnapshotFamily(sessionId)) + const plan = planSessionScrollRestore(snapshot) + if (plan.action === "bottom") { + scrollToBottom("instant") + } else { + markScrollRestored(plan.scrollTop) + restoreSessionScrollWhenReady({ + sessionId, + getElement: () => scrollRef.current, + scrollTop: plan.scrollTop, + stopScroll, + onRestored: markScrollRestored, + }) + } } - }, [loading, sessionId, scrollToBottom, settingsOverlayOpen]) + }, [loading, sessionId, isActive, scrollToBottom, scrollRef, stopScroll, settingsOverlayOpen]) return null } @@ -249,23 +228,29 @@ function ScrollOnLoad({ loading, sessionId }: { loading: boolean; sessionId: str * Tracks scroll position while the session is visible so it can be restored * after returning from Settings (StickToBottom may reset on layout changes). */ -function ScrollPositionTracker({ sessionId }: { sessionId: string }) { +function ScrollPositionTracker({ + sessionId, + isActive, +}: { + sessionId: string + isActive: boolean +}) { const { scrollRef } = useStickToBottomContext() - const setScrollTop = useSetAtom(sessionScrollTopFamily(sessionId)) + const setSnapshot = useSetAtom(sessionScrollSnapshotFamily(sessionId)) const settingsOverlayOpen = useAtomValue(settingsOverlayOpenAtom) useEffect(() => { const element = scrollRef.current - if (!element) return + if (!element || !isActive) return const onScroll = () => { if (settingsOverlayOpen) return - setScrollTop(element.scrollTop) + setSnapshot(snapshotFromScrollElement(element)) } element.addEventListener("scroll", onScroll, { passive: true }) return () => element.removeEventListener("scroll", onScroll) - }, [scrollRef, sessionId, setScrollTop, settingsOverlayOpen]) + }, [scrollRef, sessionId, isActive, setSnapshot, settingsOverlayOpen]) return null } @@ -315,7 +300,7 @@ function SettingsScrollGuard({ sessionId }: { sessionId: string }) { return null } -interface ScrollHandle { +export interface ChatScrollHandle { scrollToBottom: (behavior?: "instant" | "smooth") => void /** Returns the current scrollHeight of the scroll container */ getScrollHeight: () => number @@ -331,7 +316,7 @@ interface ScrollHandle { * can force a scroll-to-bottom even when the user has scrolled away. * Also exposes scroll position helpers for load-earlier anchor restore. */ -function ScrollBridge({ scrollRef }: { scrollRef: React.RefObject }) { +function ScrollBridge({ scrollRef }: { scrollRef: React.RefObject }) { const ctx = useStickToBottomContext() useImperativeHandle( scrollRef, @@ -364,7 +349,7 @@ function LoadEarlierOnScroll({ hasEarlierMessages: boolean loadingEarlier: boolean onLoadEarlier?: () => void | Promise - scrollRef: RefObject + scrollRef: RefObject }) { const { scrollRef: containerRef, stopScroll } = useStickToBottomContext() const sentinelRef = useRef(null) @@ -474,9 +459,10 @@ function turnListRevision(turns: ChatTurn[]): string { interface VirtualizedTurnListProps { turns: ChatTurn[] renderTurn: (turn: ChatTurn, index: number) => ReactNode + sessionId: string } -function VirtualizedTurnList({ turns, renderTurn }: VirtualizedTurnListProps) { +function VirtualizedTurnList({ turns, renderTurn, sessionId }: VirtualizedTurnListProps) { const { scrollRef } = useStickToBottomContext() const turnsRevision = useMemo(() => turnListRevision(turns), [turns]) const virtualizer = useVirtualizer({ @@ -484,12 +470,26 @@ function VirtualizedTurnList({ turns, renderTurn }: VirtualizedTurnListProps) { getScrollElement: () => scrollRef.current, getItemKey: (index) => turns[index]?.id ?? index, estimateSize: (index) => estimateTurnSize(turns[index]), - overscan: 8, + overscan: 5, }) useLayoutEffect(() => { + if (!isRestoringSessionScroll(sessionId)) { + virtualizer.measure() + return + } + const pending = getPendingRestoreScrollTop() + const snapshot = appStore.get(sessionScrollSnapshotFamily(sessionId)) + const plan = planSessionScrollRestore( + pending != null + ? { scrollTop: pending, atBottom: false, hasSnapshot: true } + : snapshot, + ) + if (plan.action === "restore" && scrollRef.current) { + scrollRef.current.scrollTop = plan.scrollTop + } virtualizer.measure() - }, [turnsRevision, virtualizer]) + }, [turnsRevision, virtualizer, scrollRef, sessionId]) return (
Promise onRedo?: () => Promise isReverted?: boolean - /** Revert to a specific message (for per-turn undo) */ - onRevertToMessage?: (messageId: string) => Promise - /** Fork from a turn boundary (messageId of the next turn's user message, or undefined for full fork) */ - onForkFromTurn?: (messageId?: string) => Promise + /** Fork from a turn boundary (protocol turn id, or undefined for tip fork) */ + onForkFromTurn?: (turnId?: string) => Promise + /** Edit and resend the latest user message */ + onEditUserMessage?: (messageId: string, text: string) => Promise /** Delete a specific part from a message (for error recovery) */ onDeletePart?: (sessionId: string, messageId: string, partId: string) => Promise /** Whether the review panel is open (removes max-w constraint) */ reviewPanelOpen?: boolean + /** Parent session title for fork boundary marker */ + parentSessionName?: string + /** Whether this session is the visible panel (gates scroll tracking). */ + isActive?: boolean + /** When false, only render the transcript (used by SessionShell). */ + showComposer?: boolean + /** When false, only render the composer (used by SessionShell). */ + showTranscript?: boolean + /** Shared scroll handle when composer is rendered outside the transcript. */ + externalScrollRef?: RefObject + /** Composer height when rendered outside this ChatView instance. */ + composerInsetPx?: number + /** Registers /side handler when transcript and composer are split. */ + sideQuestionHandlerRef?: MutableRefObject<((question: string) => Promise) | null> +} + +type SideCard = { + id: string + question: string + answer: string + status: "running" | "done" | "failed" } /** @@ -713,6 +736,7 @@ interface ChatViewProps { export function ChatView({ turns, loading, + showLoading = false, loadingEarlier, hasEarlierMessages, onLoadEarlier, @@ -732,10 +756,17 @@ export function ChatView({ onUndo, onRedo, isReverted, - onRevertToMessage, onForkFromTurn, + onEditUserMessage, onDeletePart, reviewPanelOpen, + parentSessionName, + isActive = true, + showComposer = true, + showTranscript = true, + externalScrollRef, + composerInsetPx, + sideQuestionHandlerRef, }: ChatViewProps) { const isWorking = agent.status === "running" const settingsOverlayOpen = useAtomValue(settingsOverlayOpenAtom) @@ -757,19 +788,123 @@ export function ChatView({ // Ref to imperatively scroll the conversation to bottom from outside the // tree (e.g. after sending a message or answering a question). - const scrollRef = useRef(null) + const internalScrollRef = useRef(null) + const scrollRef = externalScrollRef ?? internalScrollRef const composerRef = useRef(null) - const [composerInset, setComposerInset] = useState(0) + const [measuredComposerInset, setMeasuredComposerInset] = useState(0) + const composerInset = composerInsetPx ?? measuredComposerInset // Session-level error and setup phase from the session atom const sessionEntry = useAtomValue(sessionFamily(agent.sessionId)) const sessionError = sessionEntry?.error const setupPhase = sessionEntry?.setupPhase const compactionStatus = useAtomValue(compactionStatusFamily(agent.sessionId)) + const [sideCards, setSideCards] = useState([]) + + const startSideQuestion = useCallback( + async (question: string) => { + if (!agent.directory) return + const client = getProjectClient(agent.directory) + if (!client?.task?.startAgent) { + log.error("task.startAgent unavailable", { sessionId: agent.sessionId }) + return + } + const cardId = crypto.randomUUID() + setSideCards((prev) => [ + ...prev, + { id: cardId, question, answer: "", status: "running" }, + ]) + const prompt = + "You are answering a /side side question in a lightweight forked agent.\n" + + "The inherited conversation is reference context only. Do not continue or modify the " + + "main session task. Answer only this side question.\n" + + "You cannot use tools in this fork: do not read files, run commands, search, or modify code. " + + "Produce one concise answer and stop.\n\n" + + `Side question:\n${question}` + try { + const result = await client.task.startAgent({ + sessionID: agent.sessionId, + prompt, + forkTurns: "all", + maxTurns: 1, + toolPolicy: "deny_all", + ephemeral: true, + }) + const itemId = result.data.itemId + const childSessionId = itemId.startsWith("item_") ? itemId.slice("item_".length) : itemId + let answer = "" + for (let attempt = 0; attempt < 40; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 250)) + try { + const messages = await client.session.messages({ + sessionID: childSessionId, + limit: 50, + }) + const texts: string[] = [] + for (const entry of messages.data ?? []) { + if (entry.info.role !== "assistant") continue + for (const part of entry.parts ?? []) { + if (part.type === "text" && typeof part.text === "string" && part.text.trim()) { + texts.push(part.text) + } + } + } + answer = texts.join("\n").trim() + if (answer) break + } catch { + // Child may still be starting; keep polling. + } + } + setSideCards((prev) => + prev.map((card) => + card.id === cardId + ? { + ...card, + answer: answer || "No answer returned.", + status: answer ? "done" : "failed", + } + : card, + ), + ) + } catch (err) { + log.error("slash /side failed", { sessionId: agent.sessionId }, err) + setSideCards((prev) => + prev.map((card) => + card.id === cardId + ? { + ...card, + answer: err instanceof Error ? err.message : "Side question failed.", + status: "failed", + } + : card, + ), + ) + } + }, + [agent.directory, agent.sessionId], + ) + + // Clear ephemeral side cards when switching sessions. + useEffect(() => { + setSideCards([]) + }, [agent.sessionId]) + + useEffect(() => { + if (!sideQuestionHandlerRef || !isActive || showComposer) return + sideQuestionHandlerRef.current = startSideQuestion + return () => { + if (sideQuestionHandlerRef.current === startSideQuestion) { + sideQuestionHandlerRef.current = null + } + } + }, [isActive, showComposer, sideQuestionHandlerRef, startSideQuestion]) useLayoutEffect(() => { + if (composerInsetPx != null || !showComposer) { + return + } if (setupPhase) { - setComposerInset(0) + setMeasuredComposerInset(0) return } @@ -778,7 +913,7 @@ export function ChatView({ const updateComposerInset = () => { const nextInset = Math.ceil(composer.getBoundingClientRect().height) - setComposerInset((currentInset) => + setMeasuredComposerInset((currentInset) => currentInset === nextInset ? currentInset : nextInset, ) } @@ -808,7 +943,7 @@ export function ChatView({ window.removeEventListener("resize", updateComposerInset) } } - }, [setupPhase]) + }, [composerInsetPx, setupPhase, showComposer]) const effectivePermission = useAtomValue(effectivePermissionFamily(agent.sessionId)) const removePermission = useSetAtom(removePermissionAtom) @@ -903,6 +1038,23 @@ export function ChatView({ : "mx-auto w-full min-w-0 max-w-3xl" const retryStatus = sessionEntry?.retryStatus + const latestEditableUserTurnIndex = useMemo(() => { + for (let index = turns.length - 1; index >= 0; index--) { + if (!isSyntheticMessage(turns[index].userMessage)) return index + } + return -1 + }, [turns]) + + const forkBoundaryAfterIndex = useMemo( + () => + forkBoundaryAfterTurnIndex( + turns, + agent.forkFromId, + agent.atTurnId, + agent.createdAt, + ), + [agent.atTurnId, agent.createdAt, agent.forkFromId, turns], + ) const renderTurn = useCallback( (turn: ChatTurn, index: number) => { @@ -920,8 +1072,8 @@ export function ChatView({ ? retryStatus : undefined return ( + { - const nextTurn = turns[index + 1] - return onForkFromTurn(nextTurn?.userMessage.info.id) - } + ? () => onForkFromTurn(turn.turnId) + : undefined + } + onEditUserMessage={ + index === latestEditableUserTurnIndex && onEditUserMessage + ? (text) => onEditUserMessage(turn.userMessage.info.id, text) : undefined } onDeletePart={onDeletePart} @@ -954,20 +1107,31 @@ export function ChatView({ appStore.set(collaborationModeFamily(agent.sessionId), "plan") }} /> + {index === forkBoundaryAfterIndex ? ( + + ) : null} + ) }, [ agent, effectivePermission, compactionStatus, + forkBoundaryAfterIndex, handleApprovePermission, handleDenyPermission, isConnected, isWorking, + latestEditableUserTurnIndex, onDeletePart, onForkFromTurn, - onRevertToMessage, + onEditUserMessage, onSendMessage, + parentSessionName, retryStatus, turns, ], @@ -983,21 +1147,22 @@ export function ChatView({ } > {/* Chat messages -- constrained width for readability */} + {showTranscript ? (
- + - + -
+
- {loading ? ( -
- - Loading chat... -
+ {showLoading ? ( + ) : turns.length > 0 ? ( turns.length > VIRTUALIZE_TURN_THRESHOLD ? ( - + ) : ( turns.map(renderTurn) ) @@ -1024,6 +1190,26 @@ export function ChatView({
)} + {sideCards.map((card) => ( +
+
+ Side + {card.status === "running" ? ( + + ) : null} +
+

{card.question}

+ {card.answer ? ( +

{card.answer}

+ ) : ( +

Thinking…

+ )} +
+ ))} + {/* Session-level error from session.error events */} {showSessionError && sessionErrorText && (
@@ -1048,12 +1234,9 @@ export function ChatView({ className="pointer-events-none absolute inset-x-0 bottom-[var(--chat-composer-inset)] z-10 h-6 bg-gradient-to-t from-background/30 to-transparent" />
+ ) : null} - {/* Bottom input section — hidden during worktree setup since the stub session - cannot accept prompts yet. Extracted into its own component so toolbar, - popover, mention, and model-selection state changes don't re-render the - conversation turn list above. */} - {!setupPhase && ( + {showComposer && !setupPhase && (
Promise onReplyQuestion?: ChatViewProps["onReplyQuestion"] onRejectQuestion?: ChatViewProps["onRejectQuestion"] + onForkFromTurn?: ChatViewProps["onForkFromTurn"] + onStartSideQuestion?: (question: string) => Promise canRedo?: boolean onRedo?: () => Promise isReverted?: boolean - scrollRef: React.RefObject + scrollRef: React.RefObject reviewPanelOpen?: boolean } -function ChatInputSection({ +export function ChatInputSection({ agent, turns, isConnected, @@ -1128,6 +1315,8 @@ function ChatInputSection({ onDeny, onReplyQuestion, onRejectQuestion, + onForkFromTurn, + onStartSideQuestion, canRedo, onRedo, isReverted, @@ -1276,69 +1465,72 @@ function ChatInputSection({ const [, setInterruptCount] = useState(0) const interruptTimerRef = useRef | null>(null) - // Toolbar state - const [selectedModel, setSelectedModel] = useState(null) - const [selectedAgent, setSelectedAgent] = useState(null) - const [selectedVariant, setSelectedVariant] = useState(undefined) - - // Initialize model, variant, and agent from the session's last user message. + // Per-session composer settings (model / variant / agent). const sessionMessages = useAtomValue(messagesFamily(agent.sessionId)) const projectModels = useAtomValue(projectModelsAtom) - const initializedForSessionRef = useRef(null) - const resetForSessionRef = useRef(null) + const composerState = useAtomValue(sessionComposerFamily(agent.sessionId)) + const setComposerState = useSetAtom(setSessionComposerAtom) + const hydratedForMessagesRef = useRef(null) + useEffect(() => { - if (resetForSessionRef.current !== agent.sessionId) { - resetForSessionRef.current = agent.sessionId - initializedForSessionRef.current = null - const stored = agent.directory ? projectModels[agent.directory] : undefined - if (stored?.providerID && stored?.modelID) { - setSelectedModel(stored) - setSelectedVariant(stored.variant) - } else { - setSelectedModel(null) - setSelectedVariant(undefined) - } - setSelectedAgent(stored?.agent || null) + const messageKey = `${agent.sessionId}:${sessionMessages.length}` + if (hydratedForMessagesRef.current === messageKey) return + hydratedForMessagesRef.current = messageKey + const projectDefault = agent.directory ? projectModels[agent.directory] : undefined + const next = hydrateSessionComposerState(composerState, sessionMessages, projectDefault) + if ( + next.model?.providerID !== composerState.model?.providerID || + next.model?.modelID !== composerState.model?.modelID || + next.variant !== composerState.variant || + next.agent !== composerState.agent + ) { + setComposerState({ sessionId: agent.sessionId, patch: next }) } + }, [ + agent.directory, + agent.sessionId, + composerState, + projectModels, + sessionMessages, + setComposerState, + ]) + + const selectedModel = composerState.model + const selectedAgent = composerState.agent + const selectedVariant = composerState.variant + + const setSelectedModel = useCallback( + (model: ModelRef | null) => { + setComposerState({ + sessionId: agent.sessionId, + patch: { model, variant: undefined }, + userOverride: true, + }) + }, + [agent.sessionId, setComposerState], + ) - if (initializedForSessionRef.current === agent.sessionId) return - if (!sessionMessages || sessionMessages.length === 0) return - initializedForSessionRef.current = agent.sessionId - - let foundModel = false - let foundAgent = false - for (let i = sessionMessages.length - 1; i >= 0; i--) { - const msg = sessionMessages[i] - if (msg.role !== "user") continue - const dynamic = msg as Record - - if (!foundModel && "model" in msg && msg.model) { - const model = msg.model as { providerID: string; modelID: string } - if (model.providerID && model.modelID) { - setSelectedModel(model) - foundModel = true - const variant = dynamic.variant as string | undefined - if (variant) { - setSelectedVariant(variant) - } else { - setSelectedVariant(undefined) - } - } - } - - if ( - !foundAgent && - dynamic.agent && - typeof dynamic.agent === "string" && - dynamic.agent.length > 0 - ) { - setSelectedAgent(dynamic.agent) - foundAgent = true - } + const setSelectedAgent = useCallback( + (agentName: string | null) => { + setComposerState({ + sessionId: agent.sessionId, + patch: { agent: agentName }, + userOverride: true, + }) + }, + [agent.sessionId, setComposerState], + ) - if (foundModel && foundAgent) break - } - }, [sessionMessages, agent.sessionId, agent.directory, projectModels]) + const setSelectedVariant = useCallback( + (variant: string | undefined) => { + setComposerState({ + sessionId: agent.sessionId, + patch: { variant }, + userOverride: true, + }) + }, + [agent.sessionId, setComposerState], + ) const { addRecent: addRecentModel } = useModelState() @@ -1369,7 +1561,7 @@ function ChatInputSection({ if (!available.includes(selectedVariant)) { setSelectedVariant(undefined) } - }, [selectedVariant, effectiveModel, providers]) + }, [selectedVariant, effectiveModel, providers, setSelectedVariant]) const modelCapabilities = useMemo( () => getModelInputCapabilities(effectiveModel, providers?.providers ?? []), @@ -1379,26 +1571,17 @@ function ChatInputSection({ const handleModelSelect = useCallback( (model: ModelRef | null) => { setSelectedModel(model) - setSelectedVariant(undefined) if (!model) return addRecentModel(model) - if (!agent.directory) return - void persistRuntimeModelSelection(agent.directory, model).catch((err) => { - console.error("Failed to persist model selection:", err) - }) }, - [addRecentModel, agent.directory], + [addRecentModel, setSelectedModel], ) const handleVariantSelect = useCallback( (variant: string | undefined) => { setSelectedVariant(variant) - if (!variant || !agent.directory) return - void persistRuntimeModelConfigOption(agent.directory, "thought_level", variant).catch((err) => { - console.error("Failed to persist reasoning effort selection:", err) - }) }, - [agent.directory], + [setSelectedVariant], ) const slashCommandRef = useRef<{ @@ -1472,10 +1655,11 @@ function ChatInputSection({ const spaceIndex = trimmed.indexOf(" ") const cmdName = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex) + const args = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex + 1).trim() // Product requirement: Desktop slash commands are limited to first-party - // entries. Compact executes immediately; Goal/Plan become footer trigger - // chips; Research stays as slash text so Native can run it after a question. + // entries. Compact executes immediately; Goal becomes a footer trigger + // chip; /plan switches collaboration mode; Research stays as slash text. switch (cmdName.toLowerCase()) { case "compact": if (agent.directory && effectiveModel) { @@ -1493,11 +1677,28 @@ function ChatInputSection({ } } return true + case "fork": + if (onForkFromTurn) { + try { + await onForkFromTurn() + } catch (err) { + log.error("slash /fork failed", { sessionId: agent.sessionId }, err) + } + } + return true + case "side": + case "btw": { + if (!args) { + slashCommandRef.current?.setText("/side ") + return true + } + await onStartSideQuestion?.(args) + return true + } case "goal": setActiveTrigger("goal") return true case "plan": - setActiveTrigger("plan") setCollaborationMode("plan") return true case "skills": @@ -1509,7 +1710,14 @@ function ChatInputSection({ return false } }, - [agent.directory, agent.sessionId, effectiveModel, setCollaborationMode], + [ + agent.directory, + agent.sessionId, + effectiveModel, + onForkFromTurn, + onStartSideQuestion, + setCollaborationMode, + ], ) const submitTriggeredPrompt = useCallback( @@ -1958,26 +2166,17 @@ function ChatInputSection({ onSelectVariant={handleVariantSelect} disabled={!isConnected} /> - - {activeTrigger && ( - setCollaborationMode("build")} + /> + )} + {activeTrigger === "goal" && ( + setActiveTrigger(null)} /> )} diff --git a/apps/desktop/src/renderer/components/chat/composer-mode-chip.tsx b/apps/desktop/src/renderer/components/chat/composer-mode-chip.tsx new file mode 100644 index 00000000..407468e0 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/composer-mode-chip.tsx @@ -0,0 +1,76 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@devo/ui/components/tooltip" +import { cn } from "@devo/ui/lib/utils" +import { GoalIcon, ListTodoIcon, XIcon } from "lucide-react" + +export type ComposerModeChipVariant = "plan" | "goal" + +const CHIP_CONFIG = { + plan: { + icon: ListTodoIcon, + label: "Plan", + description: "Plan mode — the agent will propose a plan before building", + tooltipExtra: "Shift + Tab to toggle", + }, + goal: { + icon: GoalIcon, + label: "Goal", + description: "Goal — the next message sets a session goal", + tooltipExtra: null, + }, +} as const + +export function ComposerModeChip({ + variant, + onRemove, + disabled = false, +}: { + variant: ComposerModeChipVariant + onRemove: () => void + disabled?: boolean +}) { + const config = CHIP_CONFIG[variant] + const Icon = config.icon + const isPlan = variant === "plan" + + return ( + + + } + > + + {config.label} + + +
{config.description}
+ {config.tooltipExtra ? ( +
{config.tooltipExtra}
+ ) : null} +
+
+ ) +} diff --git a/apps/desktop/src/renderer/components/chat/fork-boundary-divider.tsx b/apps/desktop/src/renderer/components/chat/fork-boundary-divider.tsx new file mode 100644 index 00000000..69250dde --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/fork-boundary-divider.tsx @@ -0,0 +1,57 @@ +import { cn } from "@devo/ui/lib/utils" +import { useNavigate } from "@tanstack/react-router" +import { SplitIcon } from "lucide-react" + +export function ForkBoundaryDivider({ + parentName, + sourceSessionId, + projectSlug, + className, +}: { + parentName?: string + sourceSessionId?: string + projectSlug?: string + className?: string +}) { + const navigate = useNavigate() + const displayName = parentName || "source session" + const label = `Forked from ${displayName}` + + const handleNavigateToSource = () => { + if (!sourceSessionId || !projectSlug) return + navigate({ + to: "/project/$projectSlug/session/$sessionId", + params: { projectSlug, sessionId: sourceSessionId }, + }) + } + + const canNavigate = Boolean(sourceSessionId && projectSlug) + + return ( +
+