From 63917df061a238c7e03fbc61f7e68d0a5bfca1bf Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:20:52 +0530 Subject: [PATCH] feat(x/renderer): cut the active chat over to the sessions runtime (stage 5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active chat tab now renders from useSessionChat; background tabs keep the legacy cached view state until stage 7 retires it. - Send path: sessions:create + sessions:sendMessage with the structural UserMessage (userMessageContext carries datetime + middle-pane — the cache-safe per-message tier) and per-message turn config (model override + composition: workDirId/voice/search/codeMode, permissionMode -> autoPermission). Attachments/mentions ride as real content parts — the old 'as unknown as string' cast is gone. - Stop / permission / ask-human handlers route through the hook against the latest turn; permission scope is passed as audit metadata (session grants are a deferred v2 feature). Stop state clears when the turn settles. - Chat history lists sessions (sessions:list/delete); selecting one just switches the active session — the hook loads the conversation, so the 200-line run-log replay in loadRun is gone. - Work-dir persistence unchanged: the per-chat sidecar file is keyed by the session id, which the resolver reads via composition.workDirId. Renderer tsc/vite build clean; all 324 workspace tests pass; renderer lint identical to baseline (108 pre-existing findings, none in new code). Co-Authored-By: Claude Fable 5 --- apps/x/apps/renderer/src/App.tsx | 377 +++++++++---------------------- 1 file changed, 113 insertions(+), 264 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 9f635bde..61a5a471 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { useCallback, useEffect, useLayoutEffect, useState, useRef } from 'react' import { workspace } from '@x/shared'; -import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; +import { RunEvent } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; @@ -9,6 +9,7 @@ import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSqu import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; +import { useSessionChat } from '@/hooks/useSessionChat'; import { ChatHeader } from './components/chat-header'; import { ChatEmptyState } from './components/chat-empty-state'; import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions'; @@ -96,7 +97,6 @@ import { type ChatViewportAnchorState, type ChatTabViewState, type ConversationItem, - type ToolCall, createEmptyChatTabViewState, getWebSearchCardData, getAppActionCardData, @@ -126,7 +126,6 @@ import { useTheme } from '@/contexts/theme-context' type DirEntry = z.infer type RunEventType = z.infer -type ListRunsResponseType = z.infer interface TreeNode extends DirEntry { children?: TreeNode[] @@ -938,6 +937,10 @@ function App() { }, [conversation]) const [, setModelUsage] = useState(null) const [runId, setRunId] = useState(null) + // New runtime: the active session's chat data + actions. All logic lives in + // SessionChatStore (tested headlessly); the hook is a thin subscription. + // runId IS the session id in the sessions runtime. + const sessionChat = useSessionChat(runId) const runIdRef = useRef(null) const loadRunRequestIdRef = useRef(0) const [isProcessing, setIsProcessing] = useState(false) @@ -945,7 +948,7 @@ function App() { const processingRunIdsRef = useRef>(new Set()) const streamingBuffersRef = useRef>(new Map()) const [isStopping, setIsStopping] = useState(false) - const [stopClickedAt, setStopClickedAt] = useState(null) + const [, setStopClickedAt] = useState(null) const [agentId] = useState('copilot') const [presetMessage, setPresetMessage] = useState(undefined) @@ -1960,21 +1963,16 @@ function App() { // Load runs list (all pages) const loadRuns = useCallback(async () => { try { - const allRuns: RunListItem[] = [] - let cursor: string | undefined = undefined - - // Fetch all pages - do { - const result: ListRunsResponseType = await window.ipc.invoke('runs:list', { cursor }) - allRuns.push(...result.runs) - cursor = result.nextCursor - } while (cursor) - - // Filter for copilot chats only (Code-section sessions live in the Code view) - const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot' && run.useCase !== 'code_session') - setRuns(copilotRuns) + const { sessions } = await window.ipc.invoke('sessions:list', {}) + setRuns(sessions.map((entry) => ({ + id: entry.sessionId, + title: entry.title ?? 'New chat', + createdAt: entry.createdAt, + modifiedAt: entry.updatedAt, + agentId: entry.lastAgentId ?? 'copilot', + }))) } catch (err) { - console.error('Failed to load runs:', err) + console.error('Failed to load sessions:', err) } }, []) @@ -2073,193 +2071,30 @@ function App() { } }, [backgroundTasks, loadBackgroundTasks]) - // Load a specific run and populate conversation + // Switch the active session. The useSessionChat hook loads and follows the + // conversation; this only resets composer/tab state. const loadRun = useCallback(async (id: string) => { const requestId = (loadRunRequestIdRef.current += 1) + setConversation([]) + setCurrentAssistantMessage('') + setRunId(id) + setMessage('') + setIsProcessing(false) + setIsStopping(false) + setStopClickedAt(null) + setPendingPermissionRequests(new Map()) + setPendingAskHumanRequests(new Map()) + setAllPermissionRequests(new Map()) + setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) try { - const run = await window.ipc.invoke('runs:fetch', { runId: id }) - if (loadRunRequestIdRef.current !== requestId) return - - // Parse the log events into conversation items - const items: ConversationItem[] = [] - const toolCallMap = new Map() - - for (const event of run.log) { - switch (event.type) { - case 'message': { - const msg = event.message - if (msg.role === 'user' || msg.role === 'assistant') { - // Extract text content from message - let textContent = '' - let msgAttachments: ChatMessage['attachments'] = undefined - if (typeof msg.content === 'string') { - textContent = msg.content - } else if (Array.isArray(msg.content)) { - const contentParts = msg.content as Array<{ - type: string - text?: string - path?: string - filename?: string - mimeType?: string - size?: number - toolCallId?: string - toolName?: string - arguments?: ToolUIPart['input'] - }> - - textContent = contentParts - .filter((part) => part.type === 'text') - .map((part) => part.text || '') - .join('') - - const attachmentParts = contentParts.filter((part) => part.type === 'attachment' && part.path) - if (attachmentParts.length > 0) { - msgAttachments = attachmentParts.map((part) => ({ - path: part.path!, - filename: part.filename || part.path!.split('/').pop() || part.path!, - mimeType: part.mimeType || 'application/octet-stream', - size: part.size, - })) - } - - // Also extract tool-call parts from assistant messages - if (msg.role === 'assistant') { - for (const part of contentParts) { - if (part.type === 'tool-call' && part.toolCallId && part.toolName) { - const toolCall: ToolCall = { - id: part.toolCallId, - name: part.toolName, - input: normalizeToolInput(part.arguments), - status: 'pending', - timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), - } - toolCallMap.set(toolCall.id, toolCall) - items.push(toolCall) - } - } - } - } - if (textContent || msgAttachments) { - items.push({ - id: event.messageId, - role: msg.role, - content: textContent, - attachments: msgAttachments, - timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), - }) - } - } - break - } - case 'tool-invocation': { - // Update existing tool call status or create new one - const existingTool = event.toolCallId ? toolCallMap.get(event.toolCallId) : null - if (existingTool) { - existingTool.input = normalizeToolInput(event.input) - existingTool.status = 'running' - } else { - const toolCall: ToolCall = { - id: event.toolCallId || `tool-${Date.now()}-${Math.random()}`, - name: event.toolName, - input: normalizeToolInput(event.input), - status: 'running', - timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), - } - toolCallMap.set(toolCall.id, toolCall) - items.push(toolCall) - } - break - } - case 'tool-result': { - const existingTool = event.toolCallId ? toolCallMap.get(event.toolCallId) : null - if (existingTool) { - existingTool.result = event.result - existingTool.status = 'completed' - } - break - } - case 'error': { - items.push({ - id: `error-${Date.now()}-${Math.random()}`, - kind: 'error', - message: event.error, - timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), - }) - break - } - case 'llm-stream-event': { - // We don't need to reconstruct streaming events for history - // Reasoning is captured in the final message - break - } - } - } - if (loadRunRequestIdRef.current !== requestId) return - - // Track permission requests and responses from history - const allPermissionRequests = new Map>() - const permResponseMap = new Map() - const autoPermissionDecisions = new Map>() - const askHumanRequests = new Map>() - const respondedAskHumanIds = new Set() - - for (const event of run.log) { - if (event.type === 'tool-permission-request') { - allPermissionRequests.set(event.toolCall.toolCallId, event) - } else if (event.type === 'tool-permission-response') { - permResponseMap.set(event.toolCallId, event.response) - } else if (event.type === 'tool-permission-auto-decision') { - autoPermissionDecisions.set(event.toolCallId, event) - } else if (event.type === 'ask-human-request') { - askHumanRequests.set(event.toolCallId, event) - } else if (event.type === 'ask-human-response') { - respondedAskHumanIds.add(event.toolCallId) - } - } - if (loadRunRequestIdRef.current !== requestId) return - - // Separate pending vs responded permission requests - const pendingPerms = new Map>() - for (const [id, req] of allPermissionRequests.entries()) { - if (!permResponseMap.has(id)) { - pendingPerms.set(id, req) - } - } - - const pendingAsks = new Map>() - for (const [id, req] of askHumanRequests.entries()) { - if (!respondedAskHumanIds.has(id)) { - pendingAsks.set(id, req) - } - } - if (loadRunRequestIdRef.current !== requestId) return - - // Set the conversation and runId - setConversation(items) - setRunId(id) - setMessage('') - // Reconcile composer state with THIS run. Loading a run while another one - // is mid-turn (e.g. binding a code session steals the single chat tab) - // must not leave isProcessing/isStopping pointing at the old run — that - // wedges the composer: stop targets the new run (a no-op) while the old - // run's processing-end arrives flagged as non-active and clears nothing. - setIsProcessing(processingRunIdsRef.current.has(id)) - setIsStopping(false) - setStopClickedAt(null) - setCurrentAssistantMessage(streamingBuffersRef.current.get(id)?.assistant ?? '') - setPendingPermissionRequests(pendingPerms) - setPendingAskHumanRequests(pendingAsks) - setAllPermissionRequests(allPermissionRequests) - setPermissionResponses(permResponseMap) - setAutoPermissionDecisions(autoPermissionDecisions) - - // Restore the run's per-chat work directory into the tab it was loaded into. + // Restore the session's per-chat work directory into the active tab. const tabId = activeChatTabIdRef.current const wd = await loadRunWorkDir(id) if (loadRunRequestIdRef.current !== requestId) return setWorkDirByTab((prev) => ({ ...prev, [tabId]: wd })) } catch (err) { - console.error('Failed to load run:', err) + console.error('Failed to load session work dir:', err) } }, [loadRunWorkDir]) @@ -2743,15 +2578,11 @@ function App() { let currentRunId = runId let isNewRun = false let newRunCreatedAt: string | null = null + const selected = selectedModelByTabRef.current.get(submitTabId) if (!currentRunId) { - const selected = selectedModelByTabRef.current.get(submitTabId) - const run = await window.ipc.invoke('runs:create', { - agentId, - ...(selected ? { model: selected.model, provider: selected.provider } : {}), - permissionMode: permissionMode ?? 'manual', - }) - currentRunId = run.id - newRunCreatedAt = run.createdAt + const createdSession = await window.ipc.invoke('sessions:create', {}) + currentRunId = createdSession.sessionId + newRunCreatedAt = new Date().toISOString() setRunId(currentRunId) analytics.chatSessionCreated(currentRunId) // Update active chat tab's runId to the new run @@ -2770,6 +2601,30 @@ function App() { let titleSource = userMessage const hasMentions = (mentions?.length ?? 0) > 0 + // Per-message turn config. Composition inputs land in the system prompt + // via the agent resolver; keep them session-sticky where possible so the + // provider prefix cache survives across turns. + const sendConfig = { + agent: { + agentId, + overrides: { + ...(selected ? { model: { provider: selected.provider, model: selected.model } } : {}), + composition: { + workDirId: currentRunId, + ...(pendingVoiceInputRef.current ? { voiceInput: true } : {}), + ...(ttsEnabledRef.current ? { voiceOutput: ttsModeRef.current } : {}), + ...(searchEnabled ? { searchEnabled: true } : {}), + ...(codeMode ? { codeMode } : {}), + }, + }, + }, + autoPermission: (permissionMode ?? 'manual') === 'auto', + } + const userMessageContextFor = (middlePane: Awaited>) => ({ + currentDateTime: new Date().toISOString(), + middlePane: middlePane ?? { kind: 'empty' as const }, + }) + if (hasAttachments || hasMentions) { type ContentPart = | { type: 'text'; text: string } @@ -2812,17 +2667,15 @@ function App() { titleSource = stagedAttachments[0]?.filename ?? mentions?.[0]?.displayName ?? mentions?.[0]?.path ?? '' } - // Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema. - const attachmentPayload = contentParts as unknown as string const middlePaneContext = await buildMiddlePaneContext() - await window.ipc.invoke('runs:createMessage', { - runId: currentRunId, - message: attachmentPayload, - voiceInput: pendingVoiceInputRef.current || undefined, - voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, - searchEnabled: searchEnabled || undefined, - codeMode: codeMode || undefined, - middlePaneContext, + await window.ipc.invoke('sessions:sendMessage', { + sessionId: currentRunId, + input: { + role: 'user', + content: contentParts, + userMessageContext: userMessageContextFor(middlePaneContext), + }, + config: sendConfig, }) analytics.chatMessageSent({ voiceInput: pendingVoiceInputRef.current || undefined, @@ -2831,14 +2684,14 @@ function App() { }) } else { const middlePaneContext = await buildMiddlePaneContext() - await window.ipc.invoke('runs:createMessage', { - runId: currentRunId, - message: userMessage, - voiceInput: pendingVoiceInputRef.current || undefined, - voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, - searchEnabled: searchEnabled || undefined, - codeMode: codeMode || undefined, - middlePaneContext, + await window.ipc.invoke('sessions:sendMessage', { + sessionId: currentRunId, + input: { + role: 'user', + content: userMessage, + userMessageContext: userMessageContextFor(middlePaneContext), + }, + config: sendConfig, }) analytics.chatMessageSent({ voiceInput: pendingVoiceInputRef.current || undefined, @@ -2875,20 +2728,34 @@ function App() { handlePromptSubmitRef.current?.({ text: `${name} connected successfully.`, files: [] }) }, []) + // Active tab renders from the sessions hook; background tabs keep the + // legacy cached view state until stage 7 retires it. + const chatTabStatesForRender = React.useMemo(() => { + if (!sessionChat.chatState) return chatViewStateByTab + return { + ...chatViewStateByTab, + [activeChatTabId]: { runId, ...sessionChat.chatState }, + } + }, [chatViewStateByTab, sessionChat.chatState, activeChatTabId, runId]) + + // The composer's stop state clears when the active turn settles. + useEffect(() => { + if (sessionChat.chatState && !sessionChat.chatState.isProcessing) { + setIsStopping(false) + setStopClickedAt(null) + } + }, [sessionChat.chatState]) + const handleStop = useCallback(async () => { if (!runId) return - const now = Date.now() - const isForce = isStopping && stopClickedAt !== null && (now - stopClickedAt) < 2000 - - setStopClickedAt(now) + setStopClickedAt(Date.now()) setIsStopping(true) - try { - await window.ipc.invoke('runs:stop', { runId, force: isForce }) + await sessionChat.stop() } catch (error) { - console.error('Failed to stop run:', error) + console.error('Failed to stop turn:', error) } - }, [runId, isStopping, stopClickedAt]) + }, [runId, sessionChat]) const handlePermissionResponse = useCallback(async ( toolCallId: string, @@ -2898,33 +2765,17 @@ function App() { ) => { if (!runId) return - // Optimistically update the UI immediately - setPermissionResponses(prev => { - const next = new Map(prev) - next.set(toolCallId, response) - return next - }) - setPendingPermissionRequests(prev => { - const next = new Map(prev) - next.delete(toolCallId) - return next - }) - + void subflow // subflows retired with the runs runtime try { - await window.ipc.invoke('runs:authorizePermission', { - runId, - authorization: { subflow, toolCallId, response, scope } - }) + await sessionChat.respondToPermission( + toolCallId, + response === 'approve' ? 'allow' : 'deny', + scope ? { scope } : undefined, + ) } catch (error) { console.error('Failed to authorize permission:', error) - // Revert the optimistic update on error - setPermissionResponses(prev => { - const next = new Map(prev) - next.delete(toolCallId) - return next - }) } - }, [runId]) + }, [runId, sessionChat]) // Answer a mid-run permission request from a code_agent_run coding turn. The // pending ask lives on the tool call itself, so we optimistically clear it and @@ -2948,15 +2799,13 @@ function App() { const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => { if (!runId) return + void subflow // subflows retired with the runs runtime try { - await window.ipc.invoke('runs:provideHumanInput', { - runId, - reply: { subflow, toolCallId, response } - }) + await sessionChat.answerAskHuman(toolCallId, response) } catch (error) { console.error('Failed to provide human input:', error) } - }, [runId]) + }, [runId, sessionChat]) const dismissBrowserOverlay = useCallback(() => { setIsBrowserOpen(false) @@ -6007,7 +5856,7 @@ function App() { onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} onDeleteRun={async (rid) => { try { - await window.ipc.invoke('runs:delete', { runId: rid }) + await window.ipc.invoke('sessions:delete', { sessionId: rid }) await loadRuns() } catch (err) { console.error('Failed to delete run:', err) @@ -6454,11 +6303,11 @@ function App() { }} onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} onOpenFullScreen={toggleRightPaneMaximize} - conversation={conversation} - currentAssistantMessage={currentAssistantMessage} - chatTabStates={chatViewStateByTab} + conversation={sessionChat.chatState?.conversation ?? conversation} + currentAssistantMessage={sessionChat.chatState?.currentAssistantMessage ?? currentAssistantMessage} + chatTabStates={chatTabStatesForRender} viewportAnchors={chatViewportAnchorByTab} - isProcessing={isProcessing} + isProcessing={sessionChat.chatState ? sessionChat.chatState.isProcessing : isProcessing} isStopping={isStopping} onStop={handleStop} onSubmit={handlePromptSubmit}