From 251a462686b2532667dc8355fdfc30c49e8d0be6 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:59:10 +0530 Subject: [PATCH] feat(app): wire the new runtime into main + renderer shared/ipc.ts: add sessions:* channels (create / get / list / sendMessage / getHistory / listTurns / getTurn / respondToPermission / setToolResult / resumeTurn / stopTurn / delete) and the sessions:events feed; remove the runs:* channels. main: - register the sessions handlers and forward the turn event bus to renderer windows; getAgentRuntime() at startup - stop in-flight headless runs via stopTurn (live-note / bg-task) - drop the runs watcher, runs:* handlers, and the dev test-agent script renderer: - single global session-feed consumer; useSessionChat(sessionId) hook; pure turn -> chat-state mappers (agent-turn-view, session-chat-state); shared ChatConversation component - chat (main view + sidebar) renders from the session feed; per-turn model / permission mode; bg-task and live-note detail views load transcripts via sessions:getTurn; chat delete via sessions:delete - remove the dormant run-event path (handleRunEvent + runs:events) and its orphaned state - vitest + jsdom test setup Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/x/apps/main/src/ipc.ts | 155 +-- apps/x/apps/main/src/main.ts | 16 +- apps/x/apps/main/src/test-agent.ts | 19 - apps/x/apps/renderer/package.json | 11 +- apps/x/apps/renderer/src/App.tsx | 1022 ++--------------- .../renderer/src/components/bg-tasks-view.tsx | 74 +- .../src/components/chat-conversation.tsx | 146 +++ .../components/chat-input-with-mentions.tsx | 28 +- .../renderer/src/components/chat-sidebar.tsx | 203 +--- .../src/components/live-note-sidebar.tsx | 10 +- .../renderer/src/hooks/useSessionChat.test.ts | 83 ++ .../apps/renderer/src/hooks/useSessionChat.ts | 112 ++ .../renderer/src/lib/agent-turn-view.test.ts | 162 +++ .../apps/renderer/src/lib/agent-turn-view.ts | 190 +++ .../src/lib/session-chat-state.test.ts | 123 ++ .../renderer/src/lib/session-chat-state.ts | 109 ++ .../renderer/src/lib/session-feed.test.ts | 67 ++ apps/x/apps/renderer/src/lib/session-feed.ts | 34 + apps/x/apps/renderer/src/test/setup.ts | 1 + apps/x/apps/renderer/tsconfig.app.json | 3 +- apps/x/apps/renderer/vite.config.ts | 8 +- apps/x/packages/shared/src/ipc.ts | 163 ++- apps/x/pnpm-lock.yaml | 508 +++++++- apps/x/pnpm-workspace.yaml | 1 + 24 files changed, 1901 insertions(+), 1347 deletions(-) delete mode 100644 apps/x/apps/main/src/test-agent.ts create mode 100644 apps/x/apps/renderer/src/components/chat-conversation.tsx create mode 100644 apps/x/apps/renderer/src/hooks/useSessionChat.test.ts create mode 100644 apps/x/apps/renderer/src/hooks/useSessionChat.ts create mode 100644 apps/x/apps/renderer/src/lib/agent-turn-view.test.ts create mode 100644 apps/x/apps/renderer/src/lib/agent-turn-view.ts create mode 100644 apps/x/apps/renderer/src/lib/session-chat-state.test.ts create mode 100644 apps/x/apps/renderer/src/lib/session-chat-state.ts create mode 100644 apps/x/apps/renderer/src/lib/session-feed.test.ts create mode 100644 apps/x/apps/renderer/src/lib/session-feed.ts create mode 100644 apps/x/apps/renderer/src/test/setup.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index e59b1994..583aec5d 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -8,11 +8,10 @@ import { listProviders, } from './oauth-handler.js'; import { watcher as watcherCore, workspace } from '@x/core'; -import { WorkDir } from '@x/core/dist/config/config.js'; import { workspace as workspaceShared } from '@x/shared'; import * as mcpCore from '@x/core/dist/mcp/mcp.js'; -import * as runsCore from '@x/core/dist/runs/runs.js'; -import { bus } from '@x/core/dist/runs/bus.js'; +import type { AgentRuntime } from '@x/core/dist/agent-runtime/index.js'; +import type { SessionBusEvent } from '@x/shared/dist/sessions.js'; import { serviceBus } from '@x/core/dist/services/service_bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; @@ -21,7 +20,6 @@ import { promisify } from 'node:util'; import z from 'zod'; const execAsync = promisify(exec); -import { RunEvent } from '@x/shared/dist/runs.js'; import { ServiceEvent } from '@x/shared/dist/service-events.js'; import container from '@x/core/dist/di/container.js'; import { listOnboardingModels } from '@x/core/dist/models/models-dev.js'; @@ -356,15 +354,6 @@ export function stopWorkspaceWatcher(): void { changeQueue.clear(); } -function emitRunEvent(event: z.infer): void { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('runs:events', event); - } - } -} - function emitServiceEvent(event: z.infer): void { const windows = BrowserWindow.getAllWindows(); for (const win of windows) { @@ -409,6 +398,18 @@ export async function startCodeSessionStatusWatcher(): Promise { }); } +// Forward the generic event bus → renderer (runs:events). Code-mode (direct ACP +// sessions) streams its live events (code-run-event, permission, message, …) +// through this feed; chat + headless use the sessions:events feed below. +function emitRunEvent(event: z.infer): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('runs:events', event); + } + } +} + let runsWatcher: (() => void) | null = null; export async function startRunsWatcher(): Promise { if (runsWatcher) { @@ -419,6 +420,28 @@ export async function startRunsWatcher(): Promise { }); } +export function stopRunsWatcher(): void { + if (runsWatcher) { + runsWatcher(); + runsWatcher = null; + } +} + +function emitSessionEvent(event: SessionBusEvent): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('sessions:events', event); + } + } +} + +let sessionsWatcher: (() => void) | null = null; +export function startSessionsWatcher(agentRuntime: AgentRuntime): void { + if (sessionsWatcher) return; + sessionsWatcher = agentRuntime.bus.subscribe((event) => emitSessionEvent(event)); +} + let servicesWatcher: (() => void) | null = null; export async function startServicesWatcher(): Promise { if (servicesWatcher) { @@ -455,13 +478,6 @@ export function startBackgroundTaskAgentWatcher(): void { }); } -export function stopRunsWatcher(): void { - if (runsWatcher) { - runsWatcher(); - runsWatcher = null; - } -} - export function stopServicesWatcher(): void { if (servicesWatcher) { servicesWatcher(); @@ -477,7 +493,7 @@ export function stopServicesWatcher(): void { * Register all IPC handlers * Add new handlers here as you add channels to IPCChannels */ -export function setupIpcHandlers() { +export function setupIpcHandlers(agentRuntime: AgentRuntime) { // Forward knowledge commit events to renderer for panel refresh versionHistory.onCommit(() => emitKnowledgeCommitEvent()); @@ -587,68 +603,55 @@ export function setupIpcHandlers() { 'mcp:executeTool': async (_event, args) => { return { result: await mcpCore.executeTool(args.serverName, args.toolName, args.input) }; }, - 'runs:create': async (_event, args) => { - return runsCore.createRun(args); + // ── New runtime: sessions + turns ──────────────────────────────────────── + // Turn-mutating calls return the turn id immediately; the turn advances in + // the background and the renderer reconciles via the sessions:events feed. + 'sessions:create': async (_event, args) => { + return agentRuntime.sessions.createSession(args ?? undefined); }, - 'runs:createMessage': async (_event, args) => { - return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode, args.codeCwd, args.codePolicy) }; + 'sessions:get': async (_event, args) => { + return agentRuntime.sessions.getSession(args.sessionId); }, - 'runs:authorizePermission': async (_event, args) => { - await runsCore.authorizePermission(args.runId, args.authorization); + 'sessions:list': async (_event, args) => { + return { sessions: await agentRuntime.sessions.listSessions(args ?? undefined) }; + }, + 'sessions:sendMessage': async (_event, args) => { + const handle = await agentRuntime.sessions.sendMessage(args.sessionId, args.messages, args.options); + return { turnId: handle.id }; + }, + 'sessions:getHistory': async (_event, args) => { + return { messages: await agentRuntime.sessions.getHistory(args.sessionId) }; + }, + 'sessions:listTurns': async (_event, args) => { + return { turns: await agentRuntime.sessions.listTurns(args.sessionId) }; + }, + 'sessions:getTurn': async (_event, args) => { + return agentRuntime.agentLoop.getTurn(args.turnId); + }, + 'sessions:delete': async (_event, args) => { + await agentRuntime.sessions.deleteSession(args.sessionId); return { success: true }; }, + 'sessions:respondToPermission': async (_event, args) => { + const handle = agentRuntime.agentLoop.respondToPermission(args.turnId, args.toolCallId, args.decision, args.reason); + return { turnId: handle.id }; + }, + 'sessions:setToolResult': async (_event, args) => { + const handle = agentRuntime.agentLoop.setToolResult(args.turnId, { toolCallId: args.toolCallId, result: args.result }); + return { turnId: handle.id }; + }, + 'sessions:resumeTurn': async (_event, args) => { + const handle = agentRuntime.agentLoop.resumeTurn(args.turnId); + return { turnId: handle.id }; + }, + 'sessions:stopTurn': async (_event, args) => { + return agentRuntime.agentLoop.stopTurn(args.turnId); + }, 'codeRun:resolvePermission': async (_event, args) => { const registry = container.resolve('codePermissionRegistry'); registry.resolve(args.requestId, args.decision); return { success: true }; }, - 'runs:provideHumanInput': async (_event, args) => { - await runsCore.replyToHumanInputRequest(args.runId, args.reply); - return { success: true }; - }, - 'runs:stop': async (_event, args) => { - await runsCore.stop(args.runId, args.force); - return { success: true }; - }, - 'runs:fetch': async (_event, args) => { - return runsCore.fetchRun(args.runId); - }, - 'runs:list': async (_event, args) => { - return runsCore.listRuns(args.cursor); - }, - 'runs:delete': async (_event, args) => { - await runsCore.deleteRun(args.runId); - return { success: true }; - }, - 'runs:downloadLog': async (event, args) => { - const runFileName = `${args.runId}.jsonl`; - if (path.basename(runFileName) !== runFileName) { - return { success: false, error: 'Invalid run id' }; - } - - const sourcePath = path.join(WorkDir, 'runs', runFileName); - const win = BrowserWindow.fromWebContents(event.sender); - const result = await dialog.showSaveDialog(win!, { - defaultPath: `${runFileName}.log`, - filters: [ - { name: 'Chat Log', extensions: ['log'] }, - { name: 'JSONL', extensions: ['jsonl'] }, - { name: 'All Files', extensions: ['*'] }, - ], - }); - - if (result.canceled || !result.filePath) { - return { success: false }; - } - - try { - await fs.copyFile(sourcePath, result.filePath); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to download chat log'; - return { success: false, error: message }; - } - }, 'models:list': async () => { if (await isSignedIn()) { return await listGatewayModels(); @@ -1171,7 +1174,7 @@ export function setupIpcHandlers() { if (!live?.lastRunId) { return { success: false, error: 'No active run for this note' }; } - await runsCore.stop(live.lastRunId, false); + await agentRuntime.agentLoop.stopTurn(live.lastRunId); return { success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; @@ -1235,7 +1238,7 @@ export function setupIpcHandlers() { if (!task?.lastRunId) { return { success: false, error: 'No active run for this task' }; } - await runsCore.stop(task.lastRunId, false); + await agentRuntime.agentLoop.stopTurn(task.lastRunId); return { success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index a759b089..ba977cc4 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -4,15 +4,16 @@ import { setupIpcHandlers, startRunsWatcher, startCodeSessionStatusWatcher, + startSessionsWatcher, startServicesWatcher, startLiveNoteAgentWatcher, startBackgroundTaskAgentWatcher, startWorkspaceWatcher, - stopRunsWatcher, stopServicesWatcher, stopWorkspaceWatcher } from "./ipc.js"; import { disposeAllTerminals } from "./terminal.js"; +import { getAgentRuntime } from "@x/core/dist/agent-runtime/index.js"; import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; @@ -343,7 +344,11 @@ app.whenReady().then(async () => { registerBrowserControlService(new ElectronBrowserControlService()); registerNotificationService(new ElectronNotificationService()); - setupIpcHandlers(); + // The new agent runtime (sessions + agent loop + bridges). One instance for + // the app; its event bus is forwarded to renderer windows below. + const agentRuntime = await getAgentRuntime(); + + setupIpcHandlers(agentRuntime); setupBrowserEventForwarding(); createWindow(); @@ -355,7 +360,11 @@ app.whenReady().then(async () => { // Only starts once (guarded in startWorkspaceWatcher) startWorkspaceWatcher(); - // start runs watcher + // start sessions watcher (new runtime event feed → renderer) + startSessionsWatcher(agentRuntime); + + // start runs watcher — forwards the generic event bus → renderer (runs:events). + // Code-mode (direct ACP sessions) streams its live events through this feed. startRunsWatcher(); // start code-session status tracker (derives working/needs-you/idle + notifications) @@ -445,7 +454,6 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { // Clean up watcher on app quit stopWorkspaceWatcher(); - stopRunsWatcher(); stopServicesWatcher(); // Tear down any live ACP coding-agent adapter processes so they don't outlive the app. try { diff --git a/apps/x/apps/main/src/test-agent.ts b/apps/x/apps/main/src/test-agent.ts deleted file mode 100644 index 738d861a..00000000 --- a/apps/x/apps/main/src/test-agent.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as runsCore from '@x/core/dist/runs/runs.js'; -import { bus } from '@x/core/dist/runs/bus.js'; - -async function main() { - const { id } = await runsCore.createRun({ - // this expects an agent file to exist at WorkDir/agents/test-agent.md - agentId: 'test-agent', - }); - console.log(`created run: ${id}`); - - await bus.subscribe(id, async (event) => { - console.log(`got event: ${JSON.stringify(event)}`); - }); - - const msgId = await runsCore.createMessage(id, 'whats your name?'); - console.log(`created message: ${msgId}`); -} - -main(); diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index eec078d6..0a8c9f9f 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -6,7 +6,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@codemirror/language": "^6.12.3", @@ -83,6 +85,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -91,9 +96,11 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.1.1", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "catalog:" } } diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fcbc1ec7..5528afbc 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1,8 +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 type { LanguageModelUsage, ToolUIPart } from 'ai'; +import { UserContentPart, UserMessageContent } from '@x/shared/src/message.js'; import './App.css' import z from 'zod'; import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; @@ -24,6 +23,8 @@ import { UnsupportedFileViewer } from '@/components/unsupported-file-viewer'; import { getViewerType, isCacheableViewerPath } from '@/lib/file-types'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; +import { ChatConversation } from '@/components/chat-conversation'; +import { useSessionChat } from '@/hooks/useSessionChat'; import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; @@ -53,16 +54,11 @@ import { type FileMention, } from '@/components/ai-elements/prompt-input'; -import { Shimmer } from '@/components/ai-elements/shimmer'; -import { useSmoothedText } from './hooks/useSmoothedText'; -import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; +import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; -import { PermissionRequest } from '@/components/ai-elements/permission-request'; -import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'; import { TerminalOutput } from '@/components/terminal-output'; -import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, @@ -91,22 +87,18 @@ import { defaultRemarkPlugins } from 'streamdown' import remarkBreaks from 'remark-breaks' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { - type ChatMessage, type ChatViewportAnchorState, type ChatTabViewState, type ConversationItem, - type ToolCall, createEmptyChatTabViewState, getWebSearchCardData, getAppActionCardData, getComposioConnectCardData, getToolDisplayName, - groupConversationItems, inferRunTitleFromMessage, isChatMessage, isErrorMessage, isToolCall, - isToolGroup, normalizeToolInput, normalizeToolOutput, parseAttachedFiles, @@ -115,7 +107,6 @@ import { import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js' import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' -import { toast } from "sonner" import { useVoiceMode } from '@/hooks/useVoiceMode' import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' @@ -124,8 +115,6 @@ import * as analytics from '@/lib/analytics' import { useTheme } from '@/contexts/theme-context' type DirEntry = z.infer -type RunEventType = z.infer -type ListRunsResponseType = z.infer interface TreeNode extends DirEntry { children?: TreeNode[] @@ -139,11 +128,6 @@ const streamdownComponents = { pre: MarkdownPreOverride } // into
so typed line breaks are preserved without requiring blank lines. const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks] -function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) { - const smoothText = useSmoothedText(text) - return {smoothText} -} - function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) { const ref = useRef(null) const stickToBottom = useRef(true) @@ -405,23 +389,6 @@ const buildBgTaskSetupPrompt = (description: string) => const buildBgTaskEditPrompt = (slug: string) => `Let's tweak the background task \`${slug}\`. Please load the \`background-task\` skill first, read the task's current \`bg-tasks/${slug}/task.yaml\`, then ask me what I want to change.` -const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { - if (!usage) return null - const hasNumbers = Object.values(usage).some((value) => typeof value === 'number') - if (!hasNumbers) return null - const inputTokens = usage.inputTokens ?? 0 - const outputTokens = usage.outputTokens ?? 0 - const reasoningTokens = usage.reasoningTokens ?? 0 - const totalTokens = usage.totalTokens ?? inputTokens + outputTokens + reasoningTokens - return { - inputTokens, - outputTokens, - totalTokens, - cachedInputTokens: usage.cachedInputTokens ?? 0, - reasoningTokens, - } -} - // Sidebar folder ordering — listed folders appear in this order, unlisted ones follow alphabetically const FOLDER_ORDER = ['People', 'Organizations', 'Projects', 'Topics', 'Meetings', 'Agent Notes', 'Notes'] @@ -880,16 +847,15 @@ function App() { return } }, [conversation]) - const [, setModelUsage] = useState(null) const [runId, setRunId] = useState(null) const runIdRef = useRef(null) const loadRunRequestIdRef = useRef(0) const [isProcessing, setIsProcessing] = useState(false) - const [processingRunIds, setProcessingRunIds] = useState>(new Set()) + const [processingRunIds] = useState>(new Set()) 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) @@ -901,8 +867,6 @@ function App() { const [ttsMode, setTtsMode] = useState<'summary' | 'full'>('summary') const ttsModeRef = useRef<'summary' | 'full'>('summary') const [isRecording, setIsRecording] = useState(false) - const voiceTextBufferRef = useRef('') - const spokenIndexRef = useRef(0) const isRecordingRef = useRef(false) const tts = useVoiceTTS() @@ -1823,21 +1787,15 @@ 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', { agentId: 'copilot' }) + setRuns(sessions.map((s) => ({ + id: s.id, + ...(s.title ? { title: s.title } : {}), + createdAt: s.createdAt, + agentId: s.agentId ?? 'copilot', + }))) } catch (err) { - console.error('Failed to load runs:', err) + console.error('Failed to load sessions:', err) } }, []) @@ -1939,603 +1897,27 @@ function App() { // Load a specific run and populate conversation const loadRun = useCallback(async (id: string) => { const requestId = (loadRunRequestIdRef.current += 1) + // The session's conversation loads via useSessionChat once runId changes; + // here we only point the active tab at the session and restore its work dir. + setRunId(id) + setMessage('') 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. 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]) - const getStreamingBuffer = useCallback((id: string) => { - const existing = streamingBuffersRef.current.get(id) - if (existing) return existing - const next = { assistant: '' } - streamingBuffersRef.current.set(id, next) - return next - }, []) - const appendStreamingBuffer = useCallback((id: string, delta: string) => { - if (!delta) return - const buffer = getStreamingBuffer(id) - buffer.assistant += delta - }, [getStreamingBuffer]) - - const clearStreamingBuffer = useCallback((id: string) => { - streamingBuffersRef.current.delete(id) - }, []) - - const handleRunEvent = useCallback((event: RunEventType) => { - const activeRunId = runIdRef.current - const isActiveRun = event.runId === activeRunId - - console.log('Run event:', event.type, event) - - switch (event.type) { - case 'run-processing-start': - setProcessingRunIds(prev => { - const next = new Set(prev) - next.add(event.runId) - return next - }) - if (!isActiveRun) return - setIsProcessing(true) - setModelUsage(null) - // Reset voice buffer for new response - voiceTextBufferRef.current = '' - spokenIndexRef.current = 0 - break - - case 'run-processing-end': - setProcessingRunIds(prev => { - const next = new Set(prev) - next.delete(event.runId) - return next - }) - void loadRuns() - clearStreamingBuffer(event.runId) - if (!isActiveRun) return - setIsProcessing(false) - setIsStopping(false) - setStopClickedAt(null) - break - - case 'start': - // Run creation alone isn't a turn. Code-session runs are created when - // the session is (no message follows until the user sends one), so - // marking them processing here would never be cleared — and wedge the - // composer (Stop shown, send blocked) once the session binds a chat tab. - if (event.useCase === 'code_session') return - setProcessingRunIds(prev => { - if (prev.has(event.runId)) return prev - const next = new Set(prev) - next.add(event.runId) - return next - }) - if (!isActiveRun) return - setIsProcessing(true) - setCurrentAssistantMessage('') - setModelUsage(null) - break - - case 'llm-stream-event': - { - const llmEvent = event.event - // Fallback: if processing-start is missed/out-of-order, stream activity still means run is active. - setProcessingRunIds(prev => { - if (prev.has(event.runId)) return prev - const next = new Set(prev) - next.add(event.runId) - return next - }) - if (!isActiveRun) { - if (llmEvent.type === 'text-delta' && llmEvent.delta) { - appendStreamingBuffer(event.runId, llmEvent.delta) - } - return - } - setIsProcessing(true) - if (llmEvent.type === 'text-delta' && llmEvent.delta) { - appendStreamingBuffer(event.runId, llmEvent.delta) - setCurrentAssistantMessage(prev => prev + llmEvent.delta) - - // Extract tags and send to TTS when enabled - voiceTextBufferRef.current += llmEvent.delta - const remaining = voiceTextBufferRef.current.substring(spokenIndexRef.current) - const voiceRegex = /([\s\S]*?)<\/voice>/g - let voiceMatch: RegExpExecArray | null - while ((voiceMatch = voiceRegex.exec(remaining)) !== null) { - const voiceContent = voiceMatch[1].trim() - console.log('[voice] extracted voice tag:', voiceContent) - if (voiceContent && ttsEnabledRef.current) { - ttsRef.current.speak(voiceContent) - } - spokenIndexRef.current += voiceMatch.index + voiceMatch[0].length - } - } else if (llmEvent.type === 'tool-call') { - setConversation(prev => [...prev, { - id: llmEvent.toolCallId || `tool-${Date.now()}`, - name: llmEvent.toolName || 'tool', - input: normalizeToolInput(llmEvent.input as ToolUIPart['input']), - status: 'running', - timestamp: Date.now(), - }]) - } else if (llmEvent.type === 'finish-step') { - const nextUsage = normalizeUsage(llmEvent.usage) - if (nextUsage) { - setModelUsage(nextUsage) - } - } - } - break - - case 'message': - { - const msg = event.message - if (msg.role === 'user' && typeof msg.content === 'string') { - const inferredTitle = inferRunTitleFromMessage(msg.content) - if (inferredTitle) { - setRuns(prev => prev.map(run => ( - run.id === event.runId && !run.title - ? { ...run, title: inferredTitle } - : run - ))) - } - } - if (!isActiveRun) { - if (msg.role === 'assistant') { - clearStreamingBuffer(event.runId) - } - return - } - if (msg.role === 'assistant') { - setCurrentAssistantMessage(currentMsg => { - if (currentMsg) { - const cleanedContent = currentMsg.replace(/<\/?voice>/g, '') - setConversation(prev => { - const exists = prev.some(m => - m.id === event.messageId && 'role' in m && m.role === 'assistant' - ) - if (exists) return prev - return [...prev, { - id: event.messageId, - role: 'assistant', - content: cleanedContent, - timestamp: Date.now(), - }] - }) - } - return '' - }) - clearStreamingBuffer(event.runId) - } - } - break - - case 'tool-invocation': - { - if (!isActiveRun) return - const parsedInput = normalizeToolInput(event.input) - setConversation(prev => { - let matched = false - const next = prev.map(item => { - if ( - isToolCall(item) - && (event.toolCallId ? item.id === event.toolCallId : item.name === event.toolName) - ) { - matched = true - return { ...item, input: parsedInput, status: 'running' as const } - } - return item - }) - if (!matched) { - next.push({ - id: event.toolCallId ?? `tool-${Date.now()}`, - name: event.toolName, - input: parsedInput, - status: 'running', - timestamp: Date.now(), - }) - } - return next - }) - break - } - - case 'tool-result': - { - if (!isActiveRun) return - setConversation(prev => { - let matched = false - const next = prev.map(item => { - if ( - isToolCall(item) - && (event.toolCallId ? item.id === event.toolCallId : item.name === event.toolName) - ) { - matched = true - return { - ...item, - result: event.result as ToolUIPart['output'], - status: 'completed' as const, - // a code_agent_run finished — drop any lingering permission card - pendingCodePermission: null, - } - } - return item - }) - if (!matched) { - next.push({ - id: event.toolCallId ?? `tool-${Date.now()}`, - name: event.toolName, - input: {}, - result: event.result as ToolUIPart['output'], - status: 'completed', - timestamp: Date.now(), - }) - } - return next - }) - - if (event.toolCallId) { - setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false) - } - - // Handle app-navigation tool results — trigger UI side effects - if (event.toolName === 'app-navigation') { - const result = event.result as { success?: boolean; action?: string; [key: string]: unknown } | undefined - if (result?.success) { - pendingAppNavRef.current = result - } - } - - break - } - - case 'tool-output-stream': { - if (!isActiveRun) return - setConversation(prev => prev.map(item => { - if ( - isToolCall(item) - && item.id === event.toolCallId - ) { - if (!item.streamingOutput) { - setToolOpenForTab(activeChatTabIdRef.current, item.id, true) - } - return { ...item, streamingOutput: (item.streamingOutput ?? '') + event.output } - } - return item - })) - break - } - - case 'tool-permission-request': { - if (!isActiveRun) return - const key = event.toolCall.toolCallId - setPendingPermissionRequests(prev => { - const next = new Map(prev) - next.set(key, event) - return next - }) - setAllPermissionRequests(prev => { - const next = new Map(prev) - next.set(key, event) - return next - }) - break - } - - case 'tool-permission-response': { - if (!isActiveRun) return - setPendingPermissionRequests(prev => { - const next = new Map(prev) - next.delete(event.toolCallId) - return next - }) - setPermissionResponses(prev => { - const next = new Map(prev) - next.set(event.toolCallId, event.response) - return next - }) - break - } - - case 'code-run-event': { - if (!isActiveRun) return - setConversation(prev => prev.map(item => { - if (isToolCall(item) && item.id === event.toolCallId) { - const existing = item.codeRunEvents ?? [] - if (existing.length === 0) { - setToolOpenForTab(activeChatTabIdRef.current, item.id, true) - } - return { ...item, codeRunEvents: [...existing, event.event] } - } - return item - })) - break - } - - case 'code-run-permission-request': { - if (!isActiveRun) return - setConversation(prev => prev.map(item => { - if (isToolCall(item) && item.id === event.toolCallId) { - setToolOpenForTab(activeChatTabIdRef.current, item.id, true) - return { ...item, pendingCodePermission: { requestId: event.requestId, ask: event.ask } } - } - return item - })) - break - } - - case 'tool-permission-auto-decision': { - if (!isActiveRun) return - setAutoPermissionDecisions(prev => { - const next = new Map(prev) - next.set(event.toolCallId, event) - return next - }) - break - } - - case 'ask-human-request': { - if (!isActiveRun) return - const key = event.toolCallId - setPendingAskHumanRequests(prev => { - const next = new Map(prev) - next.set(key, event) - return next - }) - break - } - - case 'ask-human-response': { - if (!isActiveRun) return - setPendingAskHumanRequests(prev => { - const next = new Map(prev) - next.delete(event.toolCallId) - return next - }) - break - } - - case 'run-stopped': - setProcessingRunIds(prev => { - const next = new Set(prev) - next.delete(event.runId) - return next - }) - clearStreamingBuffer(event.runId) - if (!isActiveRun) return - setIsProcessing(false) - setIsStopping(false) - setStopClickedAt(null) - // Clear pending requests since they've been aborted - setPendingPermissionRequests(new Map()) - setPendingAskHumanRequests(new Map()) - // Flush any streaming content as a message - setCurrentAssistantMessage(currentMsg => { - if (currentMsg) { - setConversation(prev => [...prev, { - id: `assistant-stopped-${Date.now()}`, - role: 'assistant', - content: currentMsg, - timestamp: Date.now(), - }]) - } - return '' - }) - break - - case 'error': - setProcessingRunIds(prev => { - const next = new Set(prev) - next.delete(event.runId) - return next - }) - clearStreamingBuffer(event.runId) - if (!isActiveRun) return - setIsProcessing(false) - setIsStopping(false) - setStopClickedAt(null) - setConversation(prev => [...prev, { - id: `error-${Date.now()}`, - kind: 'error', - message: event.error, - timestamp: Date.now(), - }]) - if (!matchBillingError(event.error)) { - toast.error(event.error.split('\n')[0] || 'Model error') - } - console.error('Run error:', event.error) - break - } - }, [appendStreamingBuffer, clearStreamingBuffer, loadRuns]) - - // Listen to run events - use refs/callbacks to avoid stale closure issues. - useEffect(() => { - const cleanup = window.ipc.on('runs:events', ((event: unknown) => { - handleRunEvent(event as RunEventType) - }) as (event: null) => void) - return cleanup - }, [handleRunEvent]) + // New runtime: the active session's chat data + actions live in useSessionChat + // (feed subscription + the turnToChatState mapper). App just consumes it; the + // render reads sessionChat.chatState for the active tab (see activeChatTabState). + const sessionChat = useSessionChat(runId) + const activeIsProcessing = sessionChat.chatState?.isProcessing ?? false + const activeIsThinking = sessionChat.chatState?.isThinking ?? false type MiddlePaneContextPayload = | { kind: 'note'; path: string; content: string } @@ -2573,142 +1955,88 @@ function App() { codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode, ) => { - if (isProcessing) return + if (isProcessing || activeIsProcessing) return const submitTabId = activeChatTabIdRef.current const { text } = message const userMessage = text.trim() - const hasAttachments = stagedAttachments.length > 0 - if (!userMessage && !hasAttachments) return + if (!userMessage && stagedAttachments.length === 0) return setMessage('') - const userMessageId = `user-${Date.now()}` - const displayAttachments: ChatMessage['attachments'] = hasAttachments - ? stagedAttachments.map((attachment) => ({ - path: attachment.path, - filename: attachment.filename, - mimeType: attachment.mimeType, - size: attachment.size, - thumbnailUrl: attachment.thumbnailUrl, - })) - : undefined - setConversation((prev) => [...prev, { - id: userMessageId, - role: 'user', - content: userMessage, - attachments: displayAttachments, - timestamp: Date.now(), - }]) - setChatViewportAnchor(submitTabId, userMessageId) - try { + // currentRunId holds the SESSION id (chat = session in the new runtime). let currentRunId = runId let isNewRun = false let newRunCreatedAt: string | null = null 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 session = await window.ipc.invoke('sessions:create', { agentId }) + currentRunId = session.id + newRunCreatedAt = session.createdAt setRunId(currentRunId) analytics.chatSessionCreated(currentRunId) - // Update active chat tab's runId to the new run setChatTabs((prev) => prev.map((tab) => ( - tab.id === submitTabId - ? { ...tab, runId: currentRunId } - : tab + tab.id === submitTabId ? { ...tab, runId: currentRunId } : tab ))) - // Flush this tab's pending work directory onto the freshly created run so - // the agent picks it up on the first turn. Done before createMessage below. const pendingWorkDir = workDirByTabRef.current[submitTabId] ?? null if (pendingWorkDir) await persistRunWorkDir(currentRunId, pendingWorkDir) isNewRun = true } + // Build the user message content: text alone, or content parts when there + // are @-mentions / attachments. convertFromMessages lists attachment parts + // for the model (it reads them with file tools). let titleSource = userMessage const hasMentions = (mentions?.length ?? 0) > 0 - - if (hasAttachments || hasMentions) { - type ContentPart = - | { type: 'text'; text: string } - | { - type: 'attachment' - path: string - filename: string - mimeType: string - size?: number - lineNumber?: number - } - - const contentParts: ContentPart[] = [] - - if (mentions && mentions.length > 0) { - for (const mention of mentions) { - contentParts.push({ - type: 'attachment', - path: mention.path, - filename: mention.displayName || mention.path.split('/').pop() || mention.path, - mimeType: 'text/markdown', - ...(mention.lineNumber !== undefined ? { lineNumber: mention.lineNumber } : {}), - }) - } + const hasStagedAttachments = stagedAttachments.length > 0 + let userContent: z.infer + if (hasMentions || hasStagedAttachments) { + const parts: z.infer[] = [] + for (const mention of mentions ?? []) { + parts.push({ + type: 'attachment', + path: mention.path, + filename: mention.displayName || mention.path.split('/').pop() || mention.path, + mimeType: 'text/markdown', + ...(mention.lineNumber !== undefined ? { lineNumber: mention.lineNumber } : {}), + }) } - for (const attachment of stagedAttachments) { - contentParts.push({ + parts.push({ type: 'attachment', path: attachment.path, filename: attachment.filename, mimeType: attachment.mimeType, - size: attachment.size, + ...(attachment.size !== undefined ? { size: attachment.size } : {}), }) } - - if (userMessage) { - contentParts.push({ type: 'text', text: userMessage }) - } else { - 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, - }) - analytics.chatMessageSent({ - voiceInput: pendingVoiceInputRef.current || undefined, - voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, - searchEnabled: searchEnabled || undefined, - }) + if (userMessage) parts.push({ type: 'text', text: userMessage }) + else titleSource = stagedAttachments[0]?.filename ?? mentions?.[0]?.displayName ?? mentions?.[0]?.path ?? '' + userContent = parts } else { - const middlePaneContext = await buildMiddlePaneContext() - await window.ipc.invoke('runs:createMessage', { - runId: currentRunId, - message: userMessage, + userContent = userMessage + } + + const selected = selectedModelByTabRef.current.get(submitTabId) + const middlePaneContext = await buildMiddlePaneContext() + await window.ipc.invoke('sessions:sendMessage', { + sessionId: currentRunId, + messages: [{ role: 'user', content: userContent }], + options: { + ...(selected ? { provider: selected.provider, model: selected.model } : {}), + permissionMode: permissionMode ?? 'manual', voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, codeMode: codeMode || undefined, - middlePaneContext, - }) - analytics.chatMessageSent({ - voiceInput: pendingVoiceInputRef.current || undefined, - voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, - searchEnabled: searchEnabled || undefined, - }) - } + ...(middlePaneContext ? { middlePaneContext } : {}), + }, + }) + analytics.chatMessageSent({ + voiceInput: pendingVoiceInputRef.current || undefined, + voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, + searchEnabled: searchEnabled || undefined, + }) pendingVoiceInputRef.current = false @@ -2737,55 +2065,28 @@ function App() { }, []) const handleStop = useCallback(async () => { - if (!runId) return - const now = Date.now() - const isForce = isStopping && stopClickedAt !== null && (now - stopClickedAt) < 2000 - - setStopClickedAt(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]) + }, [sessionChat]) const handlePermissionResponse = useCallback(async ( toolCallId: string, - subflow: string[], + _subflow: string[], response: 'approve' | 'deny', - scope?: 'once' | 'session' | 'always', ) => { - 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 - }) - + // Scope (session/always) is deferred to the SessionGrants work; all approve + // variants currently map to a one-time grant. The feed updates the card on + // the next state snapshot, so no optimistic mutation is needed here. try { - await window.ipc.invoke('runs:authorizePermission', { - runId, - authorization: { subflow, toolCallId, response, scope } - }) + await sessionChat.respondToPermission(toolCallId, response === 'approve' ? 'granted' : 'denied') } 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]) + }, [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 @@ -2807,17 +2108,13 @@ function App() { } }, []) - const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => { - if (!runId) return + const handleAskHumanResponse = useCallback(async (toolCallId: string, _subflow: string[], response: string) => { 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]) + }, [sessionChat]) const dismissBrowserOverlay = useCallback(() => { setIsBrowserOpen(false) @@ -2830,7 +2127,6 @@ function App() { setCurrentAssistantMessage('') setRunId(null) setMessage('') - setModelUsage(null) setIsProcessing(false) setPendingPermissionRequests(new Map()) setPendingAskHumanRequests(new Map()) @@ -2857,7 +2153,6 @@ function App() { setCurrentAssistantMessage('') setRunId(null) setMessage('') - setModelUsage(null) setIsProcessing(false) setPendingPermissionRequests(new Map()) setPendingAskHumanRequests(new Map()) @@ -5390,23 +4685,14 @@ function App() { return null } - const activeChatTabState = React.useMemo(() => ({ - runId, - conversation, - currentAssistantMessage, - pendingAskHumanRequests, - allPermissionRequests, - permissionResponses, - autoPermissionDecisions, - }), [ - runId, - conversation, - currentAssistantMessage, - pendingAskHumanRequests, - allPermissionRequests, - permissionResponses, - autoPermissionDecisions, - ]) + // The active tab's rendered state comes from the new runtime (useSessionChat). + // Until its first turn loads (or for a brand-new chat) fall back to an empty + // state so the composer shows. + const activeChatTabState = React.useMemo(() => ( + sessionChat.chatState + ? { runId, ...sessionChat.chatState } + : { ...createEmptyChatTabViewState(), runId } + ), [runId, sessionChat.chatState]) const emptyChatTabState = React.useMemo(() => createEmptyChatTabViewState(), []) const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => { if (tabId === activeChatTabId) return activeChatTabState @@ -5775,10 +5061,10 @@ 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) + console.error('Failed to delete chat:', err) } }} onNewChat={handleNewChatTab} @@ -6015,92 +5301,17 @@ function App() { onPickPrompt={setPresetMessage} /> ) : ( - <> - {groupConversationItems( - tabState.conversation, - (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) - ).map(item => { - if (isToolGroup(item)) { - return ( - isToolOpenForTab(tab.id, toolId)} - onToolOpenChange={(toolId, open) => setToolOpenForTab(tab.id, toolId, open)} - /> - ) - } - const autoDecision = isToolCall(item) - ? tabState.autoPermissionDecisions.get(item.id) - : undefined - const rendered = renderConversationItem( - item, - tab.id, - autoDecision?.decision === 'allow' - ? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } } - : undefined, - ) - if (isToolCall(item)) { - const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null - const permRequest = tabState.allPermissionRequests.get(item.id) - if (deniedAutoDecision || permRequest) { - const response = tabState.permissionResponses.get(item.id) || null - return ( - - {deniedAutoDecision && ( - - )} - {permRequest && ( - handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} - onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isActive && isProcessing} - response={response} - /> - )} - {rendered} - - ) - } - } - return rendered - })} - - {Array.from(tabState.pendingAskHumanRequests.values()).map((request) => ( - handleAskHumanResponse(request.toolCallId, request.subflow, response)} - isProcessing={isActive && isProcessing} - /> - ))} - - {tabState.currentAssistantMessage && ( - - - /g, '')} components={streamdownComponents} /> - - - )} - - {isActive && isProcessing && !tabState.currentAssistantMessage && ( - - - Thinking... - - - )} - + )} @@ -6209,11 +5420,12 @@ function App() { }} onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} onOpenFullScreen={toggleRightPaneMaximize} - conversation={conversation} - currentAssistantMessage={currentAssistantMessage} + conversation={activeChatTabState.conversation} + currentAssistantMessage={activeChatTabState.currentAssistantMessage} chatTabStates={chatViewStateByTab} viewportAnchors={chatViewportAnchorByTab} - isProcessing={isProcessing} + isProcessing={activeIsProcessing} + isThinking={activeIsThinking} isStopping={isStopping} onStop={handleStop} onSubmit={handlePromptSubmit} @@ -6234,20 +5446,10 @@ function App() { }} workDirByTab={workDirByTab} onWorkDirChangeForTab={setTabWorkDir} - codeSessionLocks={codeSessionLocks} - pinnedToCodeSession={ - isCodeOpen - && activeCodeSession?.session.mode === 'rowboat' - // Only while the pane is actually bound to the session — a - // palette-initiated fresh chat, for example, unbinds it. - && chatTabs.find((t) => t.id === activeChatTabId)?.runId === activeCodeSession.session.id - ? { title: activeCodeSession.session.title } - : null - } - pendingAskHumanRequests={pendingAskHumanRequests} - allPermissionRequests={allPermissionRequests} - permissionResponses={permissionResponses} - autoPermissionDecisions={autoPermissionDecisions} + pendingAskHumanRequests={activeChatTabState.pendingAskHumanRequests} + allPermissionRequests={activeChatTabState.allPermissionRequests} + permissionResponses={activeChatTabState.permissionResponses} + autoPermissionDecisions={activeChatTabState.autoPermissionDecisions} onPermissionResponse={handlePermissionResponse} onAskHumanResponse={handleAskHumanResponse} isToolOpenForTab={isToolOpenForTab} diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index 0641bc13..bd005b3e 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -7,7 +7,7 @@ import { } from 'lucide-react' import type { z } from 'zod' import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js' -import type { Run } from '@x/shared/dist/runs.js' +import type { AgentLoopTurn } from '@x/shared/src/agent-turn.js' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { Input } from '@/components/ui/input' @@ -16,7 +16,7 @@ import { useBackgroundTaskAgentStatus } from '@/hooks/use-bg-task-agent-status' import { formatRelativeTime } from '@/lib/relative-time' import { toast } from '@/lib/toast' import type { ConversationItem } from '@/lib/chat-conversation' -import { runLogToConversation } from '@/lib/run-to-conversation' +import { buildConversation } from '@/lib/agent-turn-view' import { CompactConversation } from '@/components/compact-conversation' import { RichMarkdownViewer } from '@/components/rich-markdown-viewer' import { HtmlFileViewer } from '@/components/html-file-viewer' @@ -795,38 +795,36 @@ function SetupTab({ // Runs history tab — list + drill-down transcript view // // Source of truth: `bg-tasks//runs.log` — a plain-text file with one -// runId per line (newest first). The actual transcripts live at the global -// `$WorkDir/runs/.jsonl`, so this tab fetches runIds via the bg-task -// IPC, then loads each Run through the standard `runs:fetch`. No bg-task- -// specific transcript path or schema needed. +// run id per line (newest first). Each id is a standalone turn id, so this tab +// fetches ids via the bg-task IPC, then loads each turn through +// `sessions:getTurn`. No bg-task-specific transcript path or schema needed. // --------------------------------------------------------------------------- interface RunRowSummary { runId: string createdAt?: string - trigger?: string summary?: string error?: string } -// Pull the bits we want to display for a row out of a full Run's event log. -function summarizeRun(run: z.infer): RunRowSummary { - const out: RunRowSummary = { runId: run.id, createdAt: run.createdAt, trigger: run.subUseCase } - for (const event of run.log) { - if (event.type === 'error' && typeof event.error === 'string') { - out.error = event.error - } else if (event.type === 'message' && event.message?.role === 'assistant') { - const content = event.message.content - if (typeof content === 'string') { - out.summary = content - } else if (Array.isArray(content)) { - const text = content - .filter((p) => p.type === 'text') - .map((p) => ('text' in p ? p.text : '')) - .join('') - if (text) out.summary = text - } +// Pull the bits we want to display for a row out of a turn. Scans messages from +// the end for the last assistant message and extracts its text. +function summarizeTurn(turn: z.infer): RunRowSummary { + const out: RunRowSummary = { runId: turn.id, createdAt: turn.createdAt, error: turn.error?.message ?? undefined } + for (let i = turn.messages.length - 1; i >= 0; i--) { + const message = turn.messages[i] + if (message.role !== 'assistant') continue + const content = message.content + if (typeof content === 'string') { + if (content) out.summary = content + } else if (Array.isArray(content)) { + const text = content + .filter((p) => p.type === 'text') + .map((p) => ('text' in p ? p.text : '')) + .join('') + if (text) out.summary = text } + if (out.summary) break } return out } @@ -841,17 +839,17 @@ function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask }) setLoading(true) try { const { runIds } = await window.ipc.invoke('bg-task:listRunIds', { slug, limit: 100 }) - // Fetch each Run in parallel via the canonical IPC. Runs whose - // jsonl no longer exists (deleted manually, never written, …) are - // dropped silently. + // A run id is now a turn id. Fetch each turn in parallel via the + // canonical IPC. Turns that no longer exist (deleted manually, never + // written, …) are dropped silently. const settled = await Promise.allSettled( - runIds.map(runId => window.ipc.invoke('runs:fetch', { runId })) + runIds.map(turnId => window.ipc.invoke('sessions:getTurn', { turnId })) ) const next: RunRowSummary[] = [] for (let i = 0; i < settled.length; i++) { const r = settled[i] if (r.status === 'fulfilled' && r.value) { - next.push(summarizeRun(r.value)) + next.push(summarizeTurn(r.value)) } else { // Keep the row visible with just the id so the user knows it exists. next.push({ runId: runIds[i] }) @@ -924,12 +922,6 @@ function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask }) {row.createdAt ? formatRunAt(row.createdAt) : row.runId} - {row.trigger && ( - <> - · - {row.trigger} - - )} {inFlight && ( · running )} @@ -959,7 +951,7 @@ function RunTranscriptView({ isInFlight: boolean onBack: () => void }) { - const [run, setRun] = useState | null>(null) + const [run, setRun] = useState | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -969,9 +961,8 @@ function RunTranscriptView({ setError(null) void (async () => { try { - // Bg-task transcripts now live at the global runs/ location — - // same path resolution as every other run, no special handling. - const r = await window.ipc.invoke('runs:fetch', { runId }) + // A run id is now a turn id — fetch the turn snapshot. + const r = await window.ipc.invoke('sessions:getTurn', { turnId: runId }) if (cancelled) return setRun(r) } catch (err) { @@ -985,8 +976,8 @@ function RunTranscriptView({ return () => { cancelled = true } }, [runId]) - const summary = run ? summarizeRun(run) : undefined - const items: ConversationItem[] = run ? runLogToConversation(run.log) : [] + const summary = run ? summarizeTurn(run) : undefined + const items: ConversationItem[] = run ? buildConversation(run) : [] return (
@@ -1002,7 +993,6 @@ function RunTranscriptView({
{summary?.createdAt ? formatRunAt(summary.createdAt) : runId} - {summary?.trigger && ` · ${summary.trigger}`} {isInFlight && · running}
diff --git a/apps/x/apps/renderer/src/components/chat-conversation.tsx b/apps/x/apps/renderer/src/components/chat-conversation.tsx new file mode 100644 index 00000000..0ca30716 --- /dev/null +++ b/apps/x/apps/renderer/src/components/chat-conversation.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import { useSmoothedText } from '@/hooks/useSmoothedText' +import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message' +import { Shimmer } from '@/components/ai-elements/shimmer' +import { ToolGroupComponent } from '@/components/ai-elements/tool' +import { PermissionRequest } from '@/components/ai-elements/permission-request' +import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' +import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision' +import { + groupConversationItems, + isToolCall, + isToolGroup, + type ChatTabViewState, + type ConversationItem, +} from '@/lib/chat-conversation' + +type StreamdownComponents = React.ComponentProps['components'] + +export type RenderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, +) => React.ReactNode + +type Props = { + tabState: ChatTabViewState + tabId: string + // Actively working (model/tools running) → show the "Thinking…" shimmer. The + // caller folds in "is this the active tab". + isThinking: boolean + isToolOpenForTab: (tabId: string, toolId: string) => boolean + setToolOpenForTab: (tabId: string, toolId: string, open: boolean) => void + renderItem: RenderConversationItem + onPermissionResponse: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void + onAskHumanResponse: (toolCallId: string, subflow: string[], response: string) => void + streamdownComponents: StreamdownComponents +} + +// The conversation render, extracted from App.tsx so the main view and the chat +// sidebar share one implementation. Renders grouped tool calls, per-tool +// permission / auto-decision cards, ask-human cards, the live streaming message, +// and the thinking shimmer. Pure presentation: all data + handlers come in via +// props (the per-item rendering itself is supplied as `renderItem`). +export function ChatConversation({ + tabState, + tabId, + isThinking, + isToolOpenForTab, + setToolOpenForTab, + renderItem, + onPermissionResponse, + onAskHumanResponse, + streamdownComponents, +}: Props) { + const smoothAssistant = useSmoothedText(tabState.currentAssistantMessage.replace(/<\/?voice>/g, '')) + + return ( + <> + {groupConversationItems( + tabState.conversation, + (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id), + ).map((item) => { + if (isToolGroup(item)) { + return ( + isToolOpenForTab(tabId, toolId)} + onToolOpenChange={(toolId, open) => setToolOpenForTab(tabId, toolId, open)} + /> + ) + } + const autoDecision = isToolCall(item) ? tabState.autoPermissionDecisions.get(item.id) : undefined + const rendered = renderItem( + item, + tabId, + autoDecision?.decision === 'allow' + ? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } } + : undefined, + ) + if (isToolCall(item)) { + const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null + const permRequest = tabState.allPermissionRequests.get(item.id) + if (deniedAutoDecision || permRequest) { + const response = tabState.permissionResponses.get(item.id) || null + return ( + + {deniedAutoDecision && ( + + )} + {permRequest && ( + onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + isProcessing={false} + response={response} + /> + )} + {/* While a permission is pending the tool hasn't run — show only the + card, not a misleading running tool block. The tool renders once + approved (permRequest clears). */} + {!permRequest && rendered} + + ) + } + } + return rendered + })} + + {Array.from(tabState.pendingAskHumanRequests.values()).map((request) => ( + onAskHumanResponse(request.toolCallId, request.subflow, response)} + isProcessing={false} + /> + ))} + + {tabState.currentAssistantMessage && ( + + + {smoothAssistant} + + + )} + + {isThinking && !tabState.currentAssistantMessage && ( + + + Thinking... + + + )} + + ) +} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index fcad45b4..f3b933d1 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -338,22 +338,14 @@ function ChatInputInner({ } }) - // When a run exists, freeze the dropdown to the run's resolved model+provider. + // New runtime: model + permission mode are per-TURN (sent with each message + // and recorded on the turn), so the dropdowns stay editable for the life of a + // chat. Just reset to defaults when starting a brand-new chat. useEffect(() => { if (!runId) { setLockedModel(null) setPermissionMode('auto') - return } - let cancelled = false - window.ipc.invoke('runs:fetch', { runId }).then((run) => { - if (cancelled) return - if (run.provider && run.model) { - setLockedModel({ provider: run.provider, model: run.model }) - } - setPermissionMode(run.permissionMode ?? 'manual') - }).catch(() => { /* legacy run or fetch failure — leave unlocked */ }) - return () => { cancelled = true } }, [runId]) useEffect(() => { @@ -1012,17 +1004,14 @@ function ChatInputInner({ - {runId - ? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}` - : permissionMode === 'auto' - ? 'Auto-permission on — click for manual approval prompts' - : 'Manual approval prompts — click for auto-permission'} + {permissionMode === 'auto' + ? 'Auto-permission on — click for manual approval prompts' + : 'Manual approval prompts — click for auto-permission'} )} @@ -1157,7 +1144,6 @@ function ChatInputInner({ {collapseLevel >= 6 && ( e.preventDefault()} onCheckedChange={(c) => setPermissionMode(c ? 'auto' : 'manual')} > diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 0f89570f..3ca2ee3d 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,18 +1,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowLeft, ArrowRight, Bug, MoreHorizontal, Pin } from 'lucide-react' -import { toast } from 'sonner' +import { ArrowLeft, ArrowRight } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ChatHeader } from '@/components/chat-header' import { ChatEmptyState } from '@/components/chat-empty-state' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' import { Conversation, ConversationContent, @@ -23,14 +16,10 @@ import { MessageContent, MessageResponse, } from '@/components/ai-elements/message' -import { Shimmer } from '@/components/ai-elements/shimmer' -import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool' +import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool' import { WebSearchResult } from '@/components/ai-elements/web-search-result' import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' -import { PermissionRequest } from '@/components/ai-elements/permission-request' -import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision' import { TerminalOutput } from '@/components/terminal-output' -import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' @@ -38,6 +27,7 @@ import { defaultRemarkPlugins } from 'streamdown' import remarkBreaks from 'remark-breaks' import { type ChatTab } from '@/components/tab-bar' import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' +import { ChatConversation } from '@/components/chat-conversation' import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { useSidebar } from '@/components/ui/sidebar' import { wikiLabel } from '@/lib/wiki-links' @@ -51,11 +41,9 @@ import { getWebSearchCardData, getComposioConnectCardData, getToolDisplayName, - groupConversationItems, isChatMessage, isErrorMessage, isToolCall, - isToolGroup, normalizeToolInput, normalizeToolOutput, parseAttachedFiles, @@ -142,6 +130,7 @@ interface ChatSidebarProps { chatTabStates?: Record viewportAnchors?: Record isProcessing: boolean + isThinking?: boolean isStopping?: boolean onStop?: () => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void @@ -210,6 +199,7 @@ export function ChatSidebar({ chatTabStates = {}, viewportAnchors = {}, isProcessing, + isThinking, isStopping, onStop, onSubmit, @@ -363,26 +353,6 @@ export function ChatSidebar({ if (tabId === activeChatTabId) return activeTabState return chatTabStates[tabId] ?? emptyTabState }, [activeChatTabId, activeTabState, chatTabStates, emptyTabState]) - const activeRunId = activeTabState.runId - const handleDownloadChatLog = useCallback(async () => { - if (!activeRunId) { - toast.error('No chat log available yet') - return - } - - try { - const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId }) - if (result.success) { - toast.success('Chat log saved') - } else if (result.error) { - toast.error(result.error) - } - } catch (err) { - console.error('Download chat log failed:', err) - toast.error('Failed to download chat log') - } - }, [activeRunId]) - const renderConversationItem = ( item: ConversationItem, tabId: string, @@ -564,62 +534,17 @@ export function ChatSidebar({ transition: isMaximized ? 'padding-left 200ms linear' : undefined, }} > - {pinnedToCodeSession ? ( - - -
- - {pinnedToCodeSession.title} - - Coding session - -
-
- - This chat is pinned to the coding session — leave the Code view to switch chats. - -
- ) : ( - { - const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId) - return activeTab ? getChatTabTitle(activeTab) : 'New chat' - })()} - onNewChatTab={onNewChatTab} - recentRuns={recentRuns} - activeRunId={runId} - onSelectRun={onSelectRun} - onOpenChatHistory={onOpenChatHistory} - /> - )} - - - - - - - - Chat options - - - { - void handleDownloadChatLog() - }} - > - - Download chat log - - - + { + const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId) + return activeTab ? getChatTabTitle(activeTab) : 'New chat' + })()} + onNewChatTab={onNewChatTab} + recentRuns={recentRuns} + activeRunId={runId} + onSelectRun={onSelectRun} + onOpenChatHistory={onOpenChatHistory} + /> {onOpenFullScreen && ( @@ -680,91 +605,17 @@ export function ChatSidebar({ onPickPrompt={setLocalPresetMessage} /> ) : ( - <> - {groupConversationItems( - tabState.conversation, - (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) - ).map((item) => { - if (isToolGroup(item)) { - return ( - isToolOpenForTab?.(tab.id, toolId) ?? false} - onToolOpenChange={(toolId, open) => onToolOpenChangeForTab?.(tab.id, toolId, open)} - /> - ) - } - const autoDecision = isToolCall(item) - ? tabState.autoPermissionDecisions.get(item.id) - : undefined - const rendered = renderConversationItem( - item, - tab.id, - autoDecision?.decision === 'allow' - ? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } } - : undefined, - ) - if (isToolCall(item)) { - const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null - const permRequest = tabState.allPermissionRequests.get(item.id) - if (deniedAutoDecision || (permRequest && onPermissionResponse)) { - const response = tabState.permissionResponses.get(item.id) || null - return ( - - {deniedAutoDecision && ( - - )} - {permRequest && onPermissionResponse && ( - onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} - onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isActive && isProcessing} - response={response} - /> - )} - {rendered} - - ) - } - } - return rendered - })} - - {onAskHumanResponse && Array.from(tabState.pendingAskHumanRequests.values()).map((request) => ( - onAskHumanResponse(request.toolCallId, request.subflow, response)} - isProcessing={isActive && isProcessing} - /> - ))} - - {tabState.currentAssistantMessage && ( - - - {tabState.currentAssistantMessage} - - - )} - - {isActive && isProcessing && !tabState.currentAssistantMessage && ( - - - Thinking... - - - )} - + isToolOpenForTab?.(tid, toolId) ?? false} + setToolOpenForTab={(tid, toolId, open) => onToolOpenChangeForTab?.(tid, toolId, open)} + renderItem={renderConversationItem} + onPermissionResponse={(toolCallId, subflow, response) => onPermissionResponse?.(toolCallId, subflow, response)} + onAskHumanResponse={(toolCallId, subflow, response) => onAskHumanResponse?.(toolCallId, subflow, response)} + streamdownComponents={streamdownComponents} + /> )} diff --git a/apps/x/apps/renderer/src/components/live-note-sidebar.tsx b/apps/x/apps/renderer/src/components/live-note-sidebar.tsx index 1510e6c9..00943871 100644 --- a/apps/x/apps/renderer/src/components/live-note-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/live-note-sidebar.tsx @@ -11,11 +11,11 @@ import { ChevronDown, ChevronRight, } from 'lucide-react' import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js' -import type { Run } from '@x/shared/dist/runs.js' +import type { AgentLoopTurn } from '@x/shared/src/agent-turn.js' import type z from 'zod' import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status' import { formatRelativeTime } from '@/lib/relative-time' -import { runLogToConversation } from '@/lib/run-to-conversation' +import { buildConversation } from '@/lib/agent-turn-view' import { CompactConversation } from '@/components/compact-conversation' export type OpenLiveNotePanelDetail = { @@ -661,7 +661,7 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re } function LastRunTab({ live }: { live: LiveNote }) { - const [run, setRun] = useState | null>(null) + const [run, setRun] = useState | null>(null) const [loadingRun, setLoadingRun] = useState(false) const [fetchError, setFetchError] = useState(null) @@ -679,7 +679,7 @@ function LastRunTab({ live }: { live: LiveNote }) { setFetchError(null) void (async () => { try { - const r = await window.ipc.invoke('runs:fetch', { runId }) + const r = await window.ipc.invoke('sessions:getTurn', { turnId: runId }) if (cancelled) return setRun(r) } catch (err) { @@ -704,7 +704,7 @@ function LastRunTab({ live }: { live: LiveNote }) { } const isError = !!live.lastRunError - const items = run ? runLogToConversation(run.log) : [] + const items = run ? buildConversation(run) : [] return (
diff --git a/apps/x/apps/renderer/src/hooks/useSessionChat.test.ts b/apps/x/apps/renderer/src/hooks/useSessionChat.test.ts new file mode 100644 index 00000000..577f1ce0 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useSessionChat.test.ts @@ -0,0 +1,83 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import type { AgentLoopTurn } from '@x/shared/src/agent-turn.js' +import type { SessionBusEvent } from '@x/shared/src/sessions.js' +import { subscribeSessionFeed } from '../lib/session-feed.js' +import { useSessionChat } from './useSessionChat.js' + +vi.mock('../lib/session-feed.js', () => ({ subscribeSessionFeed: vi.fn() })) + +type Turn = z.infer + +function turn(overrides: Partial = {}): Turn { + const now = '2026-06-14T00:00:00Z' + return { + id: 't1', agentId: 'copilot', provider: null, model: null, permissionMode: 'manual', + useCase: null, subUseCase: null, + sessionId: 's1', sessionSeq: 1, composeContext: null, messages: [], + permissionRequests: [], permissionDecisions: [], startedTools: [], dispatchedTools: [], + modelUsage: [], error: null, completedAt: null, createdAt: now, updatedAt: now, + ...overrides, + } +} + +let emit: (e: SessionBusEvent) => void = () => undefined +const invoke = vi.fn() + +beforeEach(() => { + vi.mocked(subscribeSessionFeed).mockImplementation((listener) => { + emit = listener + return () => undefined + }) + invoke.mockReset() + invoke.mockResolvedValue({ turns: [] }) + ;(window as unknown as { ipc: unknown }).ipc = { invoke, on: vi.fn(), send: vi.fn() } +}) + +afterEach(() => { + delete (window as unknown as { ipc?: unknown }).ipc +}) + +describe('useSessionChat', () => { + it('seeds from sessions:listTurns and derives chat state', async () => { + invoke.mockResolvedValueOnce({ turns: [turn({ messages: [{ role: 'user', content: 'hi' }], completedAt: '2026-06-14T00:00:02Z' })] }) + const { result } = renderHook(() => useSessionChat('s1')) + await waitFor(() => expect(result.current.chatState).not.toBeNull()) + expect(invoke).toHaveBeenCalledWith('sessions:listTurns', { sessionId: 's1' }) + expect(result.current.chatState?.conversation).toHaveLength(1) + expect(result.current.chatState?.isProcessing).toBe(false) + }) + + it('updates from a state snapshot and accumulates streaming text from live events', async () => { + const { result } = renderHook(() => useSessionChat('s1')) + act(() => emit({ kind: 'state', turnId: 't1', sessionId: 's1', turn: turn({ messages: [{ role: 'user', content: 'go' }] }) })) + expect(result.current.chatState?.isProcessing).toBe(true) + act(() => emit({ kind: 'event', turnId: 't1', sessionId: 's1', event: { type: 'text-delta', delta: 'streaming…' } })) + expect(result.current.chatState?.currentAssistantMessage).toBe('streaming…') + }) + + it('ignores feed events for other sessions', async () => { + const { result } = renderHook(() => useSessionChat('s1')) + act(() => emit({ kind: 'state', turnId: 'x', sessionId: 'OTHER', turn: turn({ id: 'x', sessionId: 'OTHER' }) })) + expect(result.current.chatState).toBeNull() + }) + + it('routes actions to the right IPC channels against the latest turn', async () => { + const { result } = renderHook(() => useSessionChat('s1')) + act(() => emit({ kind: 'state', turnId: 't1', sessionId: 's1', turn: turn() })) + invoke.mockResolvedValue({ turnId: 't1' }) + + await act(async () => { await result.current.sendMessage([{ role: 'user', content: 'hi' }], { model: 'gpt-x' }) }) + expect(invoke).toHaveBeenCalledWith('sessions:sendMessage', { sessionId: 's1', messages: [{ role: 'user', content: 'hi' }], options: { model: 'gpt-x' } }) + + await act(async () => { await result.current.respondToPermission('tc1', 'granted') }) + expect(invoke).toHaveBeenCalledWith('sessions:respondToPermission', { turnId: 't1', toolCallId: 'tc1', decision: 'granted' }) + + await act(async () => { await result.current.answerAskHuman('tc2', 'Yes') }) + expect(invoke).toHaveBeenCalledWith('sessions:setToolResult', { turnId: 't1', toolCallId: 'tc2', result: 'Yes' }) + + await act(async () => { await result.current.stop() }) + expect(invoke).toHaveBeenCalledWith('sessions:stopTurn', { turnId: 't1' }) + }) +}) diff --git a/apps/x/apps/renderer/src/hooks/useSessionChat.ts b/apps/x/apps/renderer/src/hooks/useSessionChat.ts new file mode 100644 index 00000000..cc2271a9 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useSessionChat.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { z } from 'zod' +import type { AgentLoopTurn } from '@x/shared/src/agent-turn.js' +import type { MessageList } from '@x/shared/src/message.js' +import type { SendMessageOptions } from '@x/shared/src/sessions.js' +import { applyOverlay, emptyOverlay, type LiveOverlay } from '../lib/agent-turn-view.js' +import { subscribeSessionFeed } from '../lib/session-feed.js' +import { turnToChatState, type SessionChatState } from '../lib/session-chat-state.js' + +type Turn = z.infer + +export type SessionChat = { + // The rendered chat state for this session, or null until its first turn + // loads. Shape matches the fields the existing chat renderer consumes. + chatState: SessionChatState | null + // The turn currently in flight / latest (target for permission, ask-human, + // and stop actions). null when the session has no turns yet. + latestTurnId: string | null + sendMessage: ( + messages: z.infer, + options?: z.infer, + ) => Promise<{ turnId: string }> + respondToPermission: (toolCallId: string, decision: 'granted' | 'denied') => Promise + answerAskHuman: (toolCallId: string, answer: string) => Promise + stop: () => Promise +} + +// Owns the session→chat data flow for one session: seeds the latest turn, +// tracks the global feed (state snapshots replace the turn + clear the live +// overlay; live events accumulate streaming text / tool output), and derives the +// renderer-facing chat state via the pure turnToChatState mapper. All state +// writes happen in async callbacks; stale state across a sessionId change is +// filtered in render. App.tsx consumes this rather than inlining the logic. +export function useSessionChat(sessionId: string | null): SessionChat { + const [live, setLive] = useState<{ turn: Turn; overlay: LiveOverlay } | null>(null) + + useEffect(() => { + if (!sessionId) return + let active = true + void window.ipc + .invoke('sessions:listTurns', { sessionId }) + .then(({ turns }) => { + const latest = turns[turns.length - 1] + if (active && latest) setLive({ turn: latest, overlay: emptyOverlay() }) + }) + .catch(() => { + // New/unreadable session; feed state events will populate it. + }) + + const unsubscribe = subscribeSessionFeed((event) => { + if (event.sessionId !== sessionId) return + if (event.kind === 'state') { + setLive({ turn: event.turn, overlay: emptyOverlay() }) + } else { + setLive((prev) => (prev ? { turn: prev.turn, overlay: applyOverlay(prev.overlay, event.event) } : prev)) + } + }) + return () => { + active = false + unsubscribe() + } + }, [sessionId]) + + // Ignore state left over from a previous sessionId until the new one loads. + const current = live && live.turn.sessionId === sessionId ? live : null + const latestTurnId = current ? current.turn.id : null + + const sendMessage = useCallback( + (messages, options) => { + if (!sessionId) return Promise.reject(new Error('No active session')) + return window.ipc.invoke('sessions:sendMessage', { + sessionId, + messages, + ...(options ? { options } : {}), + }) + }, + [sessionId], + ) + + const respondToPermission = useCallback( + async (toolCallId, decision) => { + if (!latestTurnId) return + await window.ipc.invoke('sessions:respondToPermission', { turnId: latestTurnId, toolCallId, decision }) + }, + [latestTurnId], + ) + + const answerAskHuman = useCallback( + async (toolCallId, answer) => { + if (!latestTurnId) return + await window.ipc.invoke('sessions:setToolResult', { turnId: latestTurnId, toolCallId, result: answer }) + }, + [latestTurnId], + ) + + const stop = useCallback(async () => { + if (!latestTurnId) return + await window.ipc.invoke('sessions:stopTurn', { turnId: latestTurnId }) + }, [latestTurnId]) + + return useMemo( + () => ({ + chatState: current ? turnToChatState(current.turn, current.overlay) : null, + latestTurnId, + sendMessage, + respondToPermission, + answerAskHuman, + stop, + }), + [current, latestTurnId, sendMessage, respondToPermission, answerAskHuman, stop], + ) +} diff --git a/apps/x/apps/renderer/src/lib/agent-turn-view.test.ts b/apps/x/apps/renderer/src/lib/agent-turn-view.test.ts new file mode 100644 index 00000000..da2e9d95 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/agent-turn-view.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import type { AgentLoopTurn } from '@x/shared/src/agent-turn.js' +import { + applyOverlay, + buildConversation, + emptyOverlay, + pendingAskHuman, + pendingPermissions, +} from './agent-turn-view.js' +import { isChatMessage, isToolCall } from './chat-conversation.js' + +type Turn = z.infer + +function turn(overrides: Partial = {}): Turn { + const now = '2026-06-14T00:00:00Z' + return { + id: 't1', + agentId: 'copilot', + provider: null, + model: null, + permissionMode: 'manual', + useCase: null, + subUseCase: null, + sessionId: 's1', + sessionSeq: 1, + composeContext: null, + messages: [], + permissionRequests: [], + permissionDecisions: [], + startedTools: [], + dispatchedTools: [], + modelUsage: [], + error: null, + completedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + } +} + +describe('buildConversation', () => { + it('maps user + assistant text into ordered chat messages', () => { + const items = buildConversation(turn({ + messages: [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi there' }, + ], + })) + expect(items.map((i) => (isChatMessage(i) ? `${i.role}:${i.content}` : 'x'))).toEqual([ + 'user:hello', + 'assistant:hi there', + ]) + }) + + it('builds a tool call with its result and completed status', () => { + const items = buildConversation(turn({ + messages: [ + { role: 'user', content: 'read it' }, + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'file-readText', arguments: { path: '/a' } }], + }, + { role: 'tool', content: '{"text":"hi"}', toolCallId: 'tc1', toolName: 'file-readText' }, + ], + })) + const tool = items.find(isToolCall) + expect(tool).toMatchObject({ id: 'tc1', name: 'file-readText', status: 'completed', result: { text: 'hi' } }) + }) + + it('surfaces attachment parts on a user message as chips', () => { + const items = buildConversation(turn({ + messages: [ + { + role: 'user', + content: [ + { type: 'attachment', path: '/a/photo.png', filename: 'photo.png', mimeType: 'image/png', size: 100 }, + { type: 'text', text: 'look at this' }, + ], + }, + ], + })) + const msg = items.find(isChatMessage) + expect(msg?.content).toBe('look at this') + expect(msg?.attachments).toEqual([{ path: '/a/photo.png', filename: 'photo.png', mimeType: 'image/png', size: 100 }]) + }) + + it('marks an unresolved cleared tool call as running', () => { + const items = buildConversation(turn({ + messages: [ + { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'calc', arguments: {} }] }, + ], + })) + expect(items.find(isToolCall)?.status).toBe('running') + }) +}) + +describe('pendingPermissions', () => { + it('returns tool calls awaiting a user decision with the tool call + request payload', () => { + const result = pendingPermissions(turn({ + messages: [ + { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'executeCommand', arguments: { command: 'rm -rf /' } }] }, + ], + permissionRequests: [{ toolCallId: 'tc1', request: { kind: 'command', commandNames: ['rm'] }, requestedAt: '2026-06-14T00:00:00Z' }], + })) + expect(result).toHaveLength(1) + expect(result[0].toolCall.toolCallId).toBe('tc1') + expect(result[0].toolCall.toolName).toBe('executeCommand') + expect(result[0].request).toEqual({ kind: 'command', commandNames: ['rm'] }) + }) + + it('excludes calls that already have a terminal decision', () => { + const result = pendingPermissions(turn({ + messages: [ + { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'executeCommand', arguments: {} }] }, + ], + permissionRequests: [{ toolCallId: 'tc1', request: {}, requestedAt: '2026-06-14T00:00:00Z' }], + permissionDecisions: [{ toolCallId: 'tc1', decidedBy: 'user', decision: 'granted', reason: null, decidedAt: '2026-06-14T00:00:01Z' }], + })) + expect(result).toEqual([]) + }) +}) + +describe('pendingAskHuman', () => { + it('returns unresolved ask-human calls with question and options', () => { + const result = pendingAskHuman(turn({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'ask-human', arguments: { question: 'Proceed?', options: ['Yes', 'No'] } }], + }, + ], + startedTools: [{ toolCallId: 'tc1', startedAt: '2026-06-14T00:00:00Z' }], + dispatchedTools: [{ toolCallId: 'tc1', dispatchedAt: '2026-06-14T00:00:01Z' }], + })) + expect(result).toEqual([{ toolCallId: 'tc1', question: 'Proceed?', options: ['Yes', 'No'] }]) + }) + + it('omits ask-human calls that already have an answer', () => { + const result = pendingAskHuman(turn({ + messages: [ + { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'ask-human', arguments: { question: 'Proceed?' } }] }, + { role: 'tool', content: 'Yes', toolCallId: 'tc1', toolName: 'ask-human' }, + ], + startedTools: [{ toolCallId: 'tc1', startedAt: '2026-06-14T00:00:00Z' }], + dispatchedTools: [{ toolCallId: 'tc1', dispatchedAt: '2026-06-14T00:00:01Z' }], + })) + expect(result).toEqual([]) + }) +}) + +describe('applyOverlay', () => { + it('accumulates streaming text and per-tool output, ignores other events', () => { + let overlay = emptyOverlay() + overlay = applyOverlay(overlay, { type: 'text-delta', delta: 'Hel' }) + overlay = applyOverlay(overlay, { type: 'text-delta', delta: 'lo' }) + overlay = applyOverlay(overlay, { type: 'tool-output', toolCallId: 'tc1', chunk: 'line1\n' }) + overlay = applyOverlay(overlay, { type: 'tool-output', toolCallId: 'tc1', chunk: 'line2' }) + overlay = applyOverlay(overlay, { type: 'tool-result', toolCallId: 'tc1' }) + expect(overlay).toEqual({ text: 'Hello', toolOutput: { tc1: 'line1\nline2' } }) + }) +}) diff --git a/apps/x/apps/renderer/src/lib/agent-turn-view.ts b/apps/x/apps/renderer/src/lib/agent-turn-view.ts new file mode 100644 index 00000000..4f69c706 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/agent-turn-view.ts @@ -0,0 +1,190 @@ +import { z } from 'zod' +import type { AgentLoopTurn, TurnEvent } from '@x/shared/src/agent-turn.js' +import { deriveToolCallState, deriveTurnStatus, toolCallParts } from '@x/shared/src/agent-turn.js' +import type { Message, ToolCallPart } from '@x/shared/src/message.js' +import type { ChatMessage, ConversationItem, ToolCall } from './chat-conversation.js' + +// Pure derivation of the chat view model from a turn. A turn snapshot → +// ConversationItem[] (the same shape the existing renderer renders), plus the +// pending permission / ask-human prompts. Live deltas (streaming text, tool +// output) are layered on top via LiveOverlay. Everything here is pure and +// unit-tested; the hooks are thin wrappers that feed snapshots + events in. + +type Turn = z.infer +type Msg = z.infer + +function extractText(content: unknown): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map((part) => + part && typeof part === 'object' && (part as { type?: string }).type === 'text' + ? String((part as { text?: unknown }).text ?? '') + : '', + ) + .join('') + } + return '' +} + +function extractAttachments(content: unknown): ChatMessage['attachments'] { + if (!Array.isArray(content)) return undefined + const atts = content + .filter((p) => p && typeof p === 'object' && (p as { type?: string }).type === 'attachment') + .map((p) => { + const a = p as { path: string; filename?: string; mimeType?: string; size?: number } + return { + path: a.path, + filename: a.filename || a.path.split('/').pop() || a.path, + mimeType: a.mimeType || 'application/octet-stream', + ...(a.size !== undefined ? { size: a.size } : {}), + } + }) + return atts.length > 0 ? atts : undefined +} + +function parseResult(content: string): ToolCall['result'] { + try { + return JSON.parse(content) + } catch { + return content + } +} + +// Map a derived tool-call state to the renderer's ToolCall status. +function toolStatus(state: ReturnType): ToolCall['status'] { + switch (state) { + case 'resolved': + return 'completed' + case 'awaiting-user': + return 'pending' + case 'interrupted': + return 'error' + default: + // dispatched / cleared / unevaluated / needs-classifier — work in flight + return 'running' + } +} + +// Turn messages → ordered conversation items (user/assistant bubbles + tool +// cards). Tool results from tool messages are merged into their tool call. +export function buildConversation(turn: Turn): ConversationItem[] { + const items: ConversationItem[] = [] + const toolsById = new Map() + let seq = 0 + const ts = () => Date.parse(turn.createdAt) + seq++ + + for (const message of turn.messages as Msg[]) { + if (message.role === 'user') { + const text = extractText(message.content) + const attachments = extractAttachments(message.content) + if (text || attachments) { + items.push({ + id: `u-${seq}`, + role: 'user', + content: text, + timestamp: ts(), + ...(attachments ? { attachments } : {}), + }) + } + continue + } + if (message.role === 'assistant') { + const text = extractText(message.content) + if (text) items.push({ id: `a-${seq}`, role: 'assistant', content: text, timestamp: ts() }) + if (Array.isArray(message.content)) { + for (const part of message.content) { + if (part.type !== 'tool-call') continue + const tool: ToolCall = { + id: part.toolCallId, + name: part.toolName, + input: part.arguments as ToolCall['input'], + status: toolStatus(deriveToolCallState(turn, part.toolCallId)), + timestamp: ts(), + } + toolsById.set(part.toolCallId, tool) + items.push(tool) + } + } + continue + } + if (message.role === 'tool') { + const tool = toolsById.get(message.toolCallId) + if (tool) { + tool.result = parseResult(message.content) + tool.status = toolStatus(deriveToolCallState(turn, message.toolCallId)) + } + } + } + + return items +} + +// Tool calls awaiting a user permission decision (manual mode / classifier +// abstained), with the originating tool call + the request payload the renderer +// renders into a card. +export function pendingPermissions( + turn: Turn, +): { toolCall: z.infer; request: unknown }[] { + const parts = toolCallParts(turn) + const result: { toolCall: z.infer; request: unknown }[] = [] + for (const req of turn.permissionRequests) { + if (deriveToolCallState(turn, req.toolCallId) !== 'awaiting-user') continue + const toolCall = parts.find((p) => p.toolCallId === req.toolCallId) + if (toolCall) result.push({ toolCall, request: req.request }) + } + return result +} + +// Unresolved ask-human calls (dispatched tools named "ask-human"), with the +// question + options pulled from the call arguments. +export function pendingAskHuman(turn: Turn): { toolCallId: string; question: string; options?: string[] }[] { + const out: { toolCallId: string; question: string; options?: string[] }[] = [] + for (const message of turn.messages) { + if (message.role !== 'assistant' || !Array.isArray(message.content)) continue + for (const part of message.content) { + if (part.type !== 'tool-call' || part.toolName !== 'ask-human') continue + if (deriveToolCallState(turn, part.toolCallId) !== 'dispatched') continue + const args = (part.arguments ?? {}) as { question?: unknown; options?: unknown } + out.push({ + toolCallId: part.toolCallId, + question: typeof args.question === 'string' ? args.question : '', + ...(Array.isArray(args.options) ? { options: args.options.map(String) } : {}), + }) + } + } + return out +} + +export function turnStatus(turn: Turn): ReturnType { + return deriveTurnStatus(turn) +} + +// ─── Live overlay (streaming deltas applied on top of the latest snapshot) ──── + +export type LiveOverlay = { + text: string + toolOutput: Record +} + +export const emptyOverlay = (): LiveOverlay => ({ text: '', toolOutput: {} }) + +// Accumulate a live event onto the overlay. A fresh state snapshot supersedes +// the overlay (the committed transcript now includes what was streaming), so +// the hook resets to emptyOverlay() on each snapshot. +export function applyOverlay(overlay: LiveOverlay, event: TurnEvent): LiveOverlay { + switch (event.type) { + case 'text-delta': + return { ...overlay, text: overlay.text + event.delta } + case 'tool-output': + return { + ...overlay, + toolOutput: { + ...overlay.toolOutput, + [event.toolCallId]: (overlay.toolOutput[event.toolCallId] ?? '') + event.chunk, + }, + } + default: + return overlay + } +} diff --git a/apps/x/apps/renderer/src/lib/session-chat-state.test.ts b/apps/x/apps/renderer/src/lib/session-chat-state.test.ts new file mode 100644 index 00000000..f96870f5 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-chat-state.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import type { AgentLoopTurn } from '@x/shared/src/agent-turn.js' +import { emptyOverlay } from './agent-turn-view.js' +import { turnToChatState } from './session-chat-state.js' +import { isChatMessage, isToolCall } from './chat-conversation.js' + +type Turn = z.infer + +function turn(overrides: Partial = {}): Turn { + const now = '2026-06-14T00:00:00Z' + return { + id: 't1', agentId: 'copilot', provider: null, model: null, permissionMode: 'manual', + useCase: null, subUseCase: null, + sessionId: 's1', sessionSeq: 1, composeContext: null, messages: [], + permissionRequests: [], permissionDecisions: [], startedTools: [], dispatchedTools: [], + modelUsage: [], error: null, completedAt: null, createdAt: now, updatedAt: now, + ...overrides, + } +} + +describe('turnToChatState', () => { + it('derives conversation + streaming text + not-processing for a completed turn', () => { + const state = turnToChatState( + turn({ + messages: [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + ], + completedAt: '2026-06-14T00:00:02Z', + }), + emptyOverlay(), + ) + expect(state.conversation.filter(isChatMessage).map((m) => m.role)).toEqual(['user', 'assistant']) + expect(state.currentAssistantMessage).toBe('') + expect(state.isProcessing).toBe(false) + expect(state.isThinking).toBe(false) + }) + + it('marks an in-flight (non-terminal) turn as processing and surfaces streaming text', () => { + const state = turnToChatState( + turn({ messages: [{ role: 'user', content: 'go' }] }), + { text: 'streaming…', toolOutput: {} }, + ) + expect(state.isProcessing).toBe(true) + expect(state.isThinking).toBe(true) // idle = actively working + expect(state.currentAssistantMessage).toBe('streaming…') + }) + + it('overlays live tool output onto the matching tool call', () => { + const state = turnToChatState( + turn({ + messages: [ + { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'executeCommand', arguments: {} }] }, + ], + }), + { text: '', toolOutput: { tc1: 'line1\nline2' } }, + ) + const tool = state.conversation.find(isToolCall) + expect(tool?.streamingOutput).toBe('line1\nline2') + }) + + it('exposes a pending permission as a request event keyed by tool call id', () => { + const state = turnToChatState( + turn({ + messages: [ + { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'executeCommand', arguments: { command: 'rm -rf /' } }] }, + ], + permissionRequests: [{ toolCallId: 'tc1', request: { kind: 'command', commandNames: ['rm'] }, requestedAt: '2026-06-14T00:00:00Z' }], + }), + emptyOverlay(), + ) + const req = state.allPermissionRequests.get('tc1') + expect(req?.type).toBe('tool-permission-request') + expect(req?.toolCall.toolCallId).toBe('tc1') + expect(state.isProcessing).toBe(true) // waiting on permission still blocks the composer + expect(state.isThinking).toBe(false) // but it's not "thinking" — no shimmer under the card + }) + + it('records a user decision in permissionResponses and a classifier decision in autoPermissionDecisions', () => { + const state = turnToChatState( + turn({ + permissionMode: 'auto', + messages: [ + { role: 'assistant', content: [ + { type: 'tool-call', toolCallId: 'tc1', toolName: 'executeCommand', arguments: {} }, + { type: 'tool-call', toolCallId: 'tc2', toolName: 'file-readText', arguments: {} }, + ] }, + { role: 'tool', content: 'denied', toolCallId: 'tc1', toolName: 'executeCommand' }, + ], + permissionRequests: [ + { toolCallId: 'tc1', request: { kind: 'command', commandNames: ['rm'] }, requestedAt: '2026-06-14T00:00:00Z' }, + { toolCallId: 'tc2', request: { kind: 'file', operation: 'read', paths: ['/x'], pathPrefix: '/' }, requestedAt: '2026-06-14T00:00:00Z' }, + ], + permissionDecisions: [ + { toolCallId: 'tc1', decidedBy: 'user', decision: 'denied', reason: null, decidedAt: '2026-06-14T00:00:01Z' }, + { toolCallId: 'tc2', decidedBy: 'classifier', decision: 'granted', reason: 'read-only', decidedAt: '2026-06-14T00:00:01Z' }, + ], + }), + emptyOverlay(), + ) + expect(state.permissionResponses.get('tc1')).toBe('deny') + const auto = state.autoPermissionDecisions.get('tc2') + expect(auto?.decision).toBe('allow') + expect(auto?.reason).toBe('read-only') + }) + + it('exposes an unresolved ask-human as a request event', () => { + const state = turnToChatState( + turn({ + messages: [ + { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'ask-human', arguments: { question: 'Proceed?', options: ['Yes', 'No'] } }] }, + ], + startedTools: [{ toolCallId: 'tc1', startedAt: '2026-06-14T00:00:00Z' }], + dispatchedTools: [{ toolCallId: 'tc1', dispatchedAt: '2026-06-14T00:00:01Z' }], + }), + emptyOverlay(), + ) + const ask = state.pendingAskHumanRequests.get('tc1') + expect(ask?.query).toBe('Proceed?') + expect(ask?.options).toEqual(['Yes', 'No']) + }) +}) diff --git a/apps/x/apps/renderer/src/lib/session-chat-state.ts b/apps/x/apps/renderer/src/lib/session-chat-state.ts new file mode 100644 index 00000000..c3be8712 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-chat-state.ts @@ -0,0 +1,109 @@ +import { z } from 'zod' +import type { AgentLoopTurn } from '@x/shared/src/agent-turn.js' +import { deriveTurnStatus, toolCallParts } from '@x/shared/src/agent-turn.js' +import { + AskHumanRequestEvent, + ToolPermissionAutoDecisionEvent, + ToolPermissionRequestEvent, +} from '@x/shared/src/runs.js' +import { + buildConversation, + pendingAskHuman, + pendingPermissions, + type LiveOverlay, +} from './agent-turn-view.js' +import { isToolCall, type ConversationItem, type PermissionResponse } from './chat-conversation.js' + +// Maps a session's latest turn (+ its live overlay) onto the exact ChatTabViewState +// fields the existing chat renderer consumes. Because the sessions layer +// copy-forwards the transcript, the latest turn alone reproduces the whole +// conversation, so this is all the renderer needs. Pure + unit-tested; the App +// feed effect is a thin wrapper that calls this and sets state. + +type Turn = z.infer +type PermMeta = z.infer['permission'] + +export type SessionChatState = { + conversation: ConversationItem[] + currentAssistantMessage: string + allPermissionRequests: Map> + permissionResponses: Map + autoPermissionDecisions: Map> + pendingAskHumanRequests: Map> + // The turn is "processing" (compose box blocked, Stop shown) until it reaches + // a terminal rest state — completed or errored. Waiting on a permission / + // ask-human still counts as processing; the user answers via the inline card. + isProcessing: boolean + // Actively working (model / tools running) — drives the "Thinking…" shimmer. + // False while waiting on the user, so the shimmer doesn't show under a + // permission / ask-human card. + isThinking: boolean +} + +export function turnToChatState(turn: Turn, overlay: LiveOverlay): SessionChatState { + const runId = turn.id + const status = deriveTurnStatus(turn) + const parts = toolCallParts(turn) + + const conversation = buildConversation(turn).map((item) => + isToolCall(item) && overlay.toolOutput[item.id] + ? { ...item, streamingOutput: overlay.toolOutput[item.id] } + : item, + ) + + const allPermissionRequests = new Map>() + for (const { toolCall, request } of pendingPermissions(turn)) { + allPermissionRequests.set(toolCall.toolCallId, { + runId, + type: 'tool-permission-request', + subflow: [], + toolCall, + permission: request as PermMeta, + }) + } + + const permissionResponses = new Map() + const autoPermissionDecisions = new Map>() + for (const d of turn.permissionDecisions) { + if (d.decidedBy === 'user' && (d.decision === 'granted' || d.decision === 'denied')) { + permissionResponses.set(d.toolCallId, d.decision === 'granted' ? 'approve' : 'deny') + } else if (d.decidedBy === 'classifier' && (d.decision === 'granted' || d.decision === 'denied')) { + const toolCall = parts.find((p) => p.toolCallId === d.toolCallId) + if (!toolCall) continue + const request = turn.permissionRequests.find((r) => r.toolCallId === d.toolCallId)?.request + autoPermissionDecisions.set(d.toolCallId, { + runId, + type: 'tool-permission-auto-decision', + subflow: [], + toolCallId: d.toolCallId, + toolCall, + permission: request as PermMeta, + decision: d.decision === 'granted' ? 'allow' : 'deny', + reason: d.reason, + }) + } + } + + const pendingAskHumanRequests = new Map>() + for (const q of pendingAskHuman(turn)) { + pendingAskHumanRequests.set(q.toolCallId, { + runId, + type: 'ask-human-request', + subflow: [], + toolCallId: q.toolCallId, + query: q.question, + ...(q.options ? { options: q.options } : {}), + }) + } + + return { + conversation, + currentAssistantMessage: overlay.text, + allPermissionRequests, + permissionResponses, + autoPermissionDecisions, + pendingAskHumanRequests, + isProcessing: status !== 'completed' && status !== 'error', + isThinking: status === 'idle', + } +} diff --git a/apps/x/apps/renderer/src/lib/session-feed.test.ts b/apps/x/apps/renderer/src/lib/session-feed.test.ts new file mode 100644 index 00000000..99e4e0e5 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-feed.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SessionBusEvent } from '@x/shared/src/sessions.js' + +// The feed is a module singleton, so reset modules per test for isolation. +let onHandler: ((e: SessionBusEvent) => void) | null = null +const onMock = vi.fn((_channel: string, handler: (e: SessionBusEvent) => void) => { + onHandler = handler + return () => undefined +}) + +beforeEach(() => { + vi.resetModules() + onHandler = null + onMock.mockClear() + ;(window as unknown as { ipc: unknown }).ipc = { on: onMock, invoke: vi.fn(), send: vi.fn() } +}) + +afterEach(() => { + delete (window as unknown as { ipc?: unknown }).ipc +}) + +const ev = (turnId: string): SessionBusEvent => ({ + kind: 'event', + turnId, + sessionId: 's1', + event: { type: 'text-delta', delta: 'x' }, +}) + +describe('session feed', () => { + it('registers exactly one IPC listener regardless of subscriber count', async () => { + const { subscribeSessionFeed } = await import('./session-feed.js') + subscribeSessionFeed(() => undefined) + subscribeSessionFeed(() => undefined) + expect(onMock).toHaveBeenCalledTimes(1) + expect(onMock).toHaveBeenCalledWith('sessions:events', expect.any(Function)) + }) + + it('fans out each event to every subscriber', async () => { + const { subscribeSessionFeed } = await import('./session-feed.js') + const a: SessionBusEvent[] = [] + const b: SessionBusEvent[] = [] + subscribeSessionFeed((e) => a.push(e)) + subscribeSessionFeed((e) => b.push(e)) + onHandler!(ev('t1')) + expect(a).toHaveLength(1) + expect(b).toHaveLength(1) + }) + + it('stops delivering after unsubscribe', async () => { + const { subscribeSessionFeed } = await import('./session-feed.js') + const seen: SessionBusEvent[] = [] + const off = subscribeSessionFeed((e) => seen.push(e)) + onHandler!(ev('t1')) + off() + onHandler!(ev('t2')) + expect(seen).toHaveLength(1) + }) + + it('isolates a throwing subscriber from the rest', async () => { + const { subscribeSessionFeed } = await import('./session-feed.js') + const ok: SessionBusEvent[] = [] + subscribeSessionFeed(() => { throw new Error('boom') }) + subscribeSessionFeed((e) => ok.push(e)) + expect(() => onHandler!(ev('t1'))).not.toThrow() + expect(ok).toHaveLength(1) + }) +}) diff --git a/apps/x/apps/renderer/src/lib/session-feed.ts b/apps/x/apps/renderer/src/lib/session-feed.ts new file mode 100644 index 00000000..0d89827c --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-feed.ts @@ -0,0 +1,34 @@ +import type { SessionBusEvent } from '@x/shared/src/sessions.js' + +// The ONE global consumer of the sessions:events IPC feed. A single +// window.ipc.on listener fans out in-memory to every subscriber, so hooks +// (useAgentTurn / useAgentSession) tap this shared feed instead of each opening +// their own IPC listener. Mirrors the old runtime's single global bus consumer. + +type Listener = (event: SessionBusEvent) => void + +const listeners = new Set() +let detach: (() => void) | null = null + +function ensureStarted(): void { + if (detach) return + detach = window.ipc.on('sessions:events', (event) => { + // Copy to an array first so a listener that (un)subscribes during dispatch + // doesn't mutate the set mid-iteration. + for (const listener of [...listeners]) { + try { + listener(event) + } catch { + // A misbehaving subscriber must never break the feed for others. + } + } + }) +} + +export function subscribeSessionFeed(listener: Listener): () => void { + ensureStarted() + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} diff --git a/apps/x/apps/renderer/src/test/setup.ts b/apps/x/apps/renderer/src/test/setup.ts new file mode 100644 index 00000000..a9d0dd31 --- /dev/null +++ b/apps/x/apps/renderer/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/apps/x/apps/renderer/tsconfig.app.json b/apps/x/apps/renderer/tsconfig.app.json index e6e87063..df7ed51d 100644 --- a/apps/x/apps/renderer/tsconfig.app.json +++ b/apps/x/apps/renderer/tsconfig.app.json @@ -30,5 +30,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"] } diff --git a/apps/x/apps/renderer/vite.config.ts b/apps/x/apps/renderer/vite.config.ts index 9bcca968..20b2b43b 100644 --- a/apps/x/apps/renderer/vite.config.ts +++ b/apps/x/apps/renderer/vite.config.ts @@ -1,5 +1,5 @@ import path from "path" -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' @@ -18,4 +18,10 @@ export default defineConfig({ build: { outDir: 'dist', }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + css: false, + }, }) diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 8ad606bb..6487b0de 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js'; import { ListToolsResponse } from './mcp.js'; -import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; import { LlmModelConfig } from './models.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; @@ -13,13 +12,16 @@ import { BackgroundTaskSummarySchema, TriggersSchema, } from './background-task.js'; -import { UserMessageContent } from './message.js'; +import { MessageList } from './message.js'; +import { AgentLoopTurn } from './agent-turn.js'; +import { CreateSessionInput, SendMessageOptions, Session, type SessionBusEvent } from './sessions.js'; import { RowboatApiConfig } from './rowboat-account.js'; import { ZListToolkitsResponse } from './composio.js'; import { BrowserStateSchema } from './browser-control.js'; import { BillingInfoSchema } from './billing.js'; import { EmailBlockSchema, GmailThreadSchema } from './blocks.js'; import { PermissionDecision, ApprovalPolicy, CodingAgent } from './code-mode.js'; +import { Run } from './runs.js'; import { NotificationSettingsSchema } from './notification-settings.js'; import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile } from './code-sessions.js'; @@ -236,93 +238,15 @@ const ipcSchemas = { result: z.unknown(), }), }, - 'runs:create': { - req: CreateRunOptions, - res: Run, - }, - 'runs:createMessage': { - req: z.object({ - runId: z.string(), - message: UserMessageContent, - voiceInput: z.boolean().optional(), - voiceOutput: z.enum(['summary', 'full']).optional(), - searchEnabled: z.boolean().optional(), - codeMode: z.enum(['claude', 'codex']).optional(), - // Code-section sessions pin the coding agent's working directory and - // approval policy for the whole turn (see code_agent_run overrides). - codeCwd: z.string().optional(), - codePolicy: ApprovalPolicy.optional(), - middlePaneContext: z.discriminatedUnion('kind', [ - z.object({ - kind: z.literal('note'), - path: z.string(), - content: z.string(), - }), - z.object({ - kind: z.literal('browser'), - url: z.string(), - title: z.string(), - }), - ]).optional(), - }), - res: z.object({ - messageId: z.string(), - }), - }, - 'runs:authorizePermission': { - req: z.object({ - runId: z.string(), - authorization: ToolPermissionAuthorizePayload, - }), - res: z.object({ - success: z.literal(true), - }), - }, - 'runs:provideHumanInput': { - req: z.object({ - runId: z.string(), - reply: AskHumanResponsePayload, - }), - res: z.object({ - success: z.literal(true), - }), - }, - 'runs:stop': { - req: z.object({ - runId: z.string(), - force: z.boolean().optional().default(false), - }), - res: z.object({ - success: z.literal(true), - }), - }, + // Code-mode reuses the generic runs event-log + bus (decoupled from the + // retired LLM agent runtime): fetch a session's transcript and stream its + // live events. Chat + headless use the sessions:* channels instead. 'runs:fetch': { req: z.object({ runId: z.string(), }), res: Run, }, - 'runs:list': { - req: z.object({ - cursor: z.string().optional(), - }), - res: ListRunsResponse, - }, - 'runs:delete': { - req: z.object({ - runId: z.string(), - }), - res: z.object({ success: z.boolean() }), - }, - 'runs:downloadLog': { - req: z.object({ - runId: z.string().min(1), - }), - res: z.object({ - success: z.boolean(), - error: z.string().optional(), - }), - }, 'runs:events': { req: z.null(), res: z.null(), @@ -1158,8 +1082,8 @@ const ipcSchemas = { }), }, // Returns the runIds recorded in `bg-tasks//runs.log` (newest first). - // The renderer turns each id into a full Run via the existing `runs:fetch` - // channel — bg-task transcripts now live at the global $WorkDir/runs/. + // Each id is a turn id; the renderer loads the transcript via the + // `sessions:getTurn` channel (headless runs are standalone turns). 'bg-task:listRunIds': { req: z.object({ slug: z.string(), @@ -1266,6 +1190,75 @@ const ipcSchemas = { success: z.literal(true), }), }, + // ── New runtime: sessions + turns ────────────────────────────────────────── + 'sessions:create': { + req: CreateSessionInput, + res: Session, + }, + 'sessions:get': { + req: z.object({ sessionId: z.string() }), + res: Session, + }, + 'sessions:list': { + req: z.object({ agentId: z.string().optional() }).optional().nullable(), + res: z.object({ sessions: z.array(Session) }), + }, + 'sessions:sendMessage': { + req: z.object({ + sessionId: z.string(), + messages: MessageList, + options: SendMessageOptions.optional(), + }), + res: z.object({ turnId: z.string() }), + }, + 'sessions:getHistory': { + req: z.object({ sessionId: z.string() }), + res: z.object({ messages: MessageList }), + }, + 'sessions:listTurns': { + req: z.object({ sessionId: z.string() }), + res: z.object({ turns: z.array(AgentLoopTurn) }), + }, + 'sessions:getTurn': { + req: z.object({ turnId: z.string() }), + res: AgentLoopTurn, + }, + 'sessions:delete': { + req: z.object({ sessionId: z.string() }), + res: z.object({ success: z.literal(true) }), + }, + 'sessions:respondToPermission': { + req: z.object({ + turnId: z.string(), + toolCallId: z.string(), + decision: z.enum(['granted', 'denied']), + reason: z.string().optional(), + }), + res: z.object({ turnId: z.string() }), + }, + 'sessions:setToolResult': { + req: z.object({ + turnId: z.string(), + toolCallId: z.string(), + result: z.unknown(), + }), + res: z.object({ turnId: z.string() }), + }, + 'sessions:resumeTurn': { + req: z.object({ turnId: z.string() }), + res: z.object({ turnId: z.string() }), + }, + 'sessions:stopTurn': { + req: z.object({ turnId: z.string() }), + res: AgentLoopTurn, + }, + // Broadcast feed (main → renderer): live deltas + state snapshots. Typed via + // z.custom so the renderer's `on` handler is typed without runtime validation + // (the broadcast path bypasses preload validation, like runs:events). + 'sessions:events': { + req: z.custom(), + res: z.null(), + }, } as const; // ============================================================================ diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index c6812f74..6e3bc9aa 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -332,7 +332,7 @@ importers: version: 3.3.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) recharts: specifier: ^3.8.0 - version: 3.8.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1) + version: 3.8.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1) remark-breaks: specifier: ^4.0.0 version: 4.0.0 @@ -367,6 +367,15 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.2 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/node': specifier: ^24.10.1 version: 24.10.4 @@ -391,6 +400,9 @@ importers: globals: specifier: ^16.5.0 version: 16.5.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -403,6 +415,9 @@ importers: vite: specifier: ^7.2.4 version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2) + vitest: + specifier: 'catalog:' + version: 4.1.7(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jsdom@29.1.1)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) packages/core: dependencies: @@ -541,7 +556,7 @@ importers: version: 1.1.5 vitest: specifier: 'catalog:' - version: 4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + version: 4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(jsdom@29.1.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) packages/shared: dependencies: @@ -551,6 +566,9 @@ importers: packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@agentclientprotocol/claude-agent-acp@0.39.0': resolution: {integrity: sha512-+tCm5v32L0R3zE4qjZQowfO1L/zqvQ5FapmsMSIf4gawXfTf26CG5hgz99wARdo0zn20/1eP80gzx7PbZlSX9A==} hasBin: true @@ -681,6 +699,21 @@ packages: zod: optional: true + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -940,6 +973,10 @@ packages: '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@chevrotain/cst-dts-gen@12.0.0': resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==} @@ -1061,6 +1098,42 @@ packages: peerDependencies: zod: '>=3.25.76 <5' + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.4': + resolution: {integrity: sha512-yI8kNhHiOrLb8Rlulsk07DeQz0PwyT69FX9dkz5rAp7p9RUwFKEXnZpBGzURiLHgi66YqIWxOHn1nij8Lrg27Q==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@eigenpal/docx-editor-agents@1.0.3': resolution: {integrity: sha512-Bk/J9/PBnMCOxb6w4cHQiCTuN/1C4FtZM9evC9EXXcLP13yFMdqoEqsYs+Lh3HyaRRAaCZTrkfgOZyTqqyjtwQ==} peerDependencies: @@ -1591,6 +1664,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -3472,6 +3554,29 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tiptap/core@3.22.4': resolution: {integrity: sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==} peerDependencies: @@ -3666,6 +3771,9 @@ packages: '@types/appdmg@0.5.5': resolution: {integrity: sha512-G+n6DgZTZFOteITE30LnWj+HRVIGr7wMlAiLWOO02uJFWVEitaPU9JVXm9wJokkgshBawb2O1OykdcsmkkZfgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4209,6 +4317,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -4229,6 +4341,13 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + arrify@2.0.1: resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} engines: {node: '>=8'} @@ -4665,10 +4784,17 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -4832,6 +4958,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} @@ -4855,6 +4985,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -4947,6 +5080,12 @@ packages: resolution: {integrity: sha512-FwgeAKqY2vc9eVm2V2XGg8bq25B0OQjtSDITGi9zNnvu5GbtR4WvGjM5QNld/ALB6ZbsSuHskBPK9SvPpKhsbA==} engines: {node: '>=0.10'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@0.2.2: resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} @@ -5077,6 +5216,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -5727,6 +5870,10 @@ packages: hsl-to-rgb-for-reals@1.1.1: resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} @@ -5962,6 +6109,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -6045,6 +6195,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -6287,6 +6446,10 @@ packages: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -6308,6 +6471,10 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + macos-alias@0.2.12: resolution: {integrity: sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw==} os: [darwin] @@ -6402,6 +6569,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -6581,6 +6751,10 @@ packages: min-document@2.19.2: resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -6952,6 +7126,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -7103,6 +7280,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proc-log@2.0.1: resolution: {integrity: sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -7259,6 +7440,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} peerDependencies: @@ -7354,6 +7538,10 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -7543,6 +7731,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.25.0-rc-603e6108-20241029: resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==} @@ -7783,6 +7975,10 @@ packages: resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} engines: {node: '>=0.10.0'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -7834,6 +8030,9 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -7910,6 +8109,13 @@ packages: peerDependencies: '@tiptap/core': ^3.0.1 + tldts-core@7.4.2: + resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} + + tldts@7.4.2: + resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} + hasBin: true + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -7943,9 +8149,17 @@ packages: tokenlens@1.3.1: resolution: {integrity: sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA==} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -8041,6 +8255,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.27.2: + resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} + engines: {node: '>=20.18.1'} + unicode-properties@1.4.1: resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} @@ -8293,6 +8511,10 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + wait-on@9.0.3: resolution: {integrity: sha512-13zBnyYvFDW1rBvWiJ6Av3ymAaq8EDQuvxZnPIw3g04UqGi4TyoIJABmfJ6zrvKo9yeFQExNkOk7idQbDJcuKA==} engines: {node: '>=20.0.0'} @@ -8318,6 +8540,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -8332,6 +8558,14 @@ packages: webpack-cli: optional: true + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -8399,6 +8633,10 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xmlbuilder2@2.1.2: resolution: {integrity: sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==} engines: {node: '>=8.0'} @@ -8411,6 +8649,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -8479,6 +8720,8 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@agentclientprotocol/claude-agent-acp@0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1))': dependencies: '@agentclientprotocol/sdk': 0.22.1(zod@4.2.1) @@ -8613,6 +8856,26 @@ snapshots: optionalDependencies: zod: 4.2.1 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.4(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -9225,6 +9488,10 @@ snapshots: '@braintree/sanitize-url@7.1.1': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@chevrotain/cst-dts-gen@12.0.0': dependencies: '@chevrotain/gast': 12.0.0 @@ -9519,6 +9786,30 @@ snapshots: dependencies: zod: 4.2.1 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.4(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@eigenpal/docx-editor-agents@1.0.3(ai@5.0.117(zod@4.2.1))(react@19.2.3)': dependencies: docxtemplater: 3.68.7 @@ -10179,6 +10470,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -12288,6 +12581,36 @@ snapshots: tailwindcss: 4.1.18 vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@tiptap/core@3.22.4(@tiptap/pm@3.22.4)': dependencies: '@tiptap/pm': 3.22.4 @@ -12502,6 +12825,8 @@ snapshots: '@types/node': 25.0.3 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -12941,6 +13266,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.7(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2) + '@vitest/mocker@4.1.7(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.7 @@ -13164,6 +13497,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} appdmg@0.6.6: @@ -13191,6 +13526,12 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + arrify@2.0.1: {} assertion-error@2.0.1: {} @@ -13645,8 +13986,15 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.2.2: {} + css.escape@1.5.1: {} + csstype@3.2.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): @@ -13835,6 +14183,13 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dayjs@1.11.19: {} debug@2.6.9: @@ -13847,6 +14202,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -13927,6 +14284,10 @@ snapshots: dependencies: '@xmldom/xmldom': 0.9.10 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@0.2.2: dependencies: domelementtype: 2.3.0 @@ -14116,6 +14477,8 @@ snapshots: entities@6.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} err-code@2.0.3: {} @@ -15027,6 +15390,12 @@ snapshots: hsl-to-rgb-for-reals@1.1.1: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-entities@2.6.0: {} html-to-docx@1.8.0(encoding@0.1.13): @@ -15247,6 +15616,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-property@1.0.2: @@ -15337,6 +15708,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.27.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-bigint@1.0.0: @@ -15566,6 +15963,8 @@ snapshots: lru-cache@11.2.4: {} + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -15582,6 +15981,8 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + macos-alias@0.2.12: dependencies: nan: 2.24.0 @@ -15822,6 +16223,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + mdurl@2.0.0: {} media-engine@1.0.3: {} @@ -16124,6 +16527,8 @@ snapshots: dependencies: dom-walk: 0.1.2 + min-indent@1.0.1: {} + minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -16478,6 +16883,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} pascal-case@3.1.2: @@ -16624,6 +17033,12 @@ snapshots: prettier@3.8.0: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + proc-log@2.0.1: {} process-nextick-args@2.0.1: {} @@ -16869,6 +17284,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -16960,7 +17377,7 @@ snapshots: readdirp@4.1.2: {} - recharts@3.8.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1): + recharts@3.8.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3) clsx: 2.1.1 @@ -16970,7 +17387,7 @@ snapshots: immer: 10.2.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - react-is: 16.13.1 + react-is: 17.0.2 react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 @@ -16984,6 +17401,11 @@ snapshots: dependencies: resolve: 1.22.11 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 @@ -17234,6 +17656,10 @@ snapshots: sax@1.6.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.25.0-rc-603e6108-20241029: {} scheduler@0.27.0: {} @@ -17532,6 +17958,10 @@ snapshots: strip-eof@1.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -17578,6 +18008,8 @@ snapshots: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) + symbol-tree@3.2.4: {} + tailwind-merge@3.4.0: {} tailwindcss@4.1.18: {} @@ -17658,6 +18090,12 @@ snapshots: markdown-it-task-lists: 2.1.1 prosemirror-markdown: 1.13.2 + tldts-core@7.4.2: {} + + tldts@7.4.2: + dependencies: + tldts-core: 7.4.2 + tmp-promise@3.0.3: dependencies: tmp: 0.2.5 @@ -17697,8 +18135,16 @@ snapshots: '@tokenlens/helpers': 1.3.1 '@tokenlens/models': 1.3.0 + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.2 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -17775,6 +18221,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.27.2: {} + unicode-properties@1.4.1: dependencies: base64-js: 1.5.1 @@ -17986,7 +18434,36 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 - vitest@4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)): + vitest@4.1.7(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jsdom@29.1.1)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 24.10.4 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + vitest@4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(jsdom@29.1.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.7 '@vitest/mocker': 4.1.7(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) @@ -18011,6 +18488,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 25.0.3 + jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -18035,6 +18513,10 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + wait-on@9.0.3: dependencies: axios: 1.13.2 @@ -18062,6 +18544,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + webpack-sources@3.3.3: {} webpack@5.104.1(esbuild@0.24.2): @@ -18096,6 +18580,16 @@ snapshots: - esbuild - uglify-js + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -18173,6 +18667,8 @@ snapshots: dependencies: sax: 1.6.0 + xml-name-validator@5.0.0: {} + xmlbuilder2@2.1.2: dependencies: '@oozcitak/dom': 1.15.5 @@ -18183,6 +18679,8 @@ snapshots: xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/apps/x/pnpm-workspace.yaml b/apps/x/pnpm-workspace.yaml index c3c6b7dc..fa29a44f 100644 --- a/apps/x/pnpm-workspace.yaml +++ b/apps/x/pnpm-workspace.yaml @@ -23,5 +23,6 @@ onlyBuiltDependencies: - fs-xattr - macos-alias - protobufjs + patchedDependencies: '@openai/codex@0.128.0': patches/@openai__codex@0.128.0.patch