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) <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-06-14 16:59:10 +05:30
parent c1cc5a8753
commit 251a462686
24 changed files with 1901 additions and 1347 deletions

View file

@ -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<typeof RunEvent>): 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<typeof ServiceEvent>): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
@ -409,6 +398,18 @@ export async function startCodeSessionStatusWatcher(): Promise<void> {
});
}
// 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<typeof RunEvent>): 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<void> {
if (runsWatcher) {
@ -419,6 +420,28 @@ export async function startRunsWatcher(): Promise<void> {
});
}
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<void> {
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>('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) };

View file

@ -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 {

View file

@ -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();

View file

@ -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:"
}
}

File diff suppressed because it is too large Load diff

View file

@ -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/<slug>/runs.log` — a plain-text file with one
// runId per line (newest first). The actual transcripts live at the global
// `$WorkDir/runs/<runId>.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<typeof Run>): 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<typeof AgentLoopTurn>): 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 })
<span className="font-mono text-[10.5px] text-muted-foreground">
{row.createdAt ? formatRunAt(row.createdAt) : row.runId}
</span>
{row.trigger && (
<>
<span className="text-[10.5px] text-muted-foreground">·</span>
<span className="text-[10.5px] text-muted-foreground">{row.trigger}</span>
</>
)}
{inFlight && (
<span className="text-[10.5px] text-amber-600">· running</span>
)}
@ -959,7 +951,7 @@ function RunTranscriptView({
isInFlight: boolean
onBack: () => void
}) {
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
const [run, setRun] = useState<z.infer<typeof AgentLoopTurn> | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-1 flex-col overflow-hidden">
@ -1002,7 +993,6 @@ function RunTranscriptView({
<div className="min-w-0 flex-1">
<div className="font-mono text-[10.5px] text-muted-foreground">
{summary?.createdAt ? formatRunAt(summary.createdAt) : runId}
{summary?.trigger && ` · ${summary.trigger}`}
{isInFlight && <span className="ml-1 text-amber-600">· running</span>}
</div>
</div>

View file

@ -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<typeof MessageResponse>['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 (
<ToolGroupComponent
key={item.groupId}
group={item}
isToolOpen={(toolId) => 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 (
<React.Fragment key={item.id}>
{deniedAutoDecision && (
<AutoPermissionDecision
toolCall={deniedAutoDecision.toolCall}
permission={deniedAutoDecision.permission}
decision={deniedAutoDecision.decision}
reason={deniedAutoDecision.reason}
/>
)}
{permRequest && (
<PermissionRequest
toolCall={permRequest.toolCall}
permission={permRequest.permission}
onApprove={() => 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}
</React.Fragment>
)
}
}
return rendered
})}
{Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
<AskHumanRequest
key={request.toolCallId}
query={request.query}
options={request.options}
onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
isProcessing={false}
/>
))}
{tabState.currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<MessageResponse components={streamdownComponents}>{smoothAssistant}</MessageResponse>
</MessageContent>
</Message>
)}
{isThinking && !tabState.currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer>
</MessageContent>
</Message>
)}
</>
)
}

View file

@ -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({
<button
type="button"
onClick={() => {
if (runId) return
setPermissionMode((mode) => mode === 'auto' ? 'manual' : 'auto')
}}
disabled={Boolean(runId)}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-full text-xs font-medium transition-colors",
collapseLevel >= 2 ? "w-7 justify-center" : "px-2.5",
permissionMode === 'auto'
? "bg-secondary text-foreground hover:bg-secondary/70"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
runId && "cursor-not-allowed opacity-70 hover:bg-secondary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
aria-label="Permission mode"
>
@ -1031,11 +1020,9 @@ function ChatInputInner({
</button>
</TooltipTrigger>
<TooltipContent side="top">
{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'}
</TooltipContent>
</Tooltip>
)}
@ -1157,7 +1144,6 @@ function ChatInputInner({
{collapseLevel >= 6 && (
<DropdownMenuCheckboxItem
checked={permissionMode === 'auto'}
disabled={Boolean(runId)}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(c) => setPermissionMode(c ? 'auto' : 'manual')}
>

View file

@ -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<string, ChatTabViewState>
viewportAnchors?: Record<string, ChatViewportAnchorState>
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 ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="titlebar-no-drag flex min-w-0 flex-1 items-center gap-1.5 px-3 py-2 text-sm font-medium">
<Pin className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{pinnedToCodeSession.title}</span>
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">
Coding session
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
This chat is pinned to the coding session leave the Code view to switch chats.
</TooltipContent>
</Tooltip>
) : (
<ChatHeader
activeTitle={(() => {
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
})()}
onNewChatTab={onNewChatTab}
recentRuns={recentRuns}
activeRunId={runId}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
/>
)}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Chat options"
>
<MoreHorizontal className="size-5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Chat options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem
disabled={!activeRunId}
onSelect={() => {
void handleDownloadChatLog()
}}
>
<Bug className="size-4" />
Download chat log
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChatHeader
activeTitle={(() => {
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 && (
<Tooltip>
<TooltipTrigger asChild>
@ -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 (
<ToolGroupComponent
key={item.groupId}
group={item}
isToolOpen={(toolId) => 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 (
<React.Fragment key={item.id}>
{deniedAutoDecision && (
<AutoPermissionDecision
toolCall={deniedAutoDecision.toolCall}
permission={deniedAutoDecision.permission}
decision={deniedAutoDecision.decision}
reason={deniedAutoDecision.reason}
/>
)}
{permRequest && onPermissionResponse && (
<PermissionRequest
toolCall={permRequest.toolCall}
permission={permRequest.permission}
onApprove={() => 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}
</React.Fragment>
)
}
}
return rendered
})}
{onAskHumanResponse && Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
<AskHumanRequest
key={request.toolCallId}
query={request.query}
onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
isProcessing={isActive && isProcessing}
/>
))}
{tabState.currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage}</MessageResponse>
</MessageContent>
</Message>
)}
{isActive && isProcessing && !tabState.currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer>
</MessageContent>
</Message>
)}
</>
<ChatConversation
tabState={tabState}
tabId={tab.id}
isThinking={isActive && Boolean(isThinking)}
isToolOpenForTab={(tid, toolId) => 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}
/>
)}
</ConversationContent>
<ConversationScrollButton />

View file

@ -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<z.infer<typeof Run> | null>(null)
const [run, setRun] = useState<z.infer<typeof AgentLoopTurn> | null>(null)
const [loadingRun, setLoadingRun] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(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 (
<div className="flex-1 overflow-auto px-4 py-4 space-y-4">

View file

@ -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<typeof AgentLoopTurn>
function turn(overrides: Partial<Turn> = {}): 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' })
})
})

View file

@ -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<typeof AgentLoopTurn>
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<typeof MessageList>,
options?: z.infer<typeof SendMessageOptions>,
) => Promise<{ turnId: string }>
respondToPermission: (toolCallId: string, decision: 'granted' | 'denied') => Promise<void>
answerAskHuman: (toolCallId: string, answer: string) => Promise<void>
stop: () => Promise<void>
}
// 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<SessionChat['sendMessage']>(
(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<SessionChat['respondToPermission']>(
async (toolCallId, decision) => {
if (!latestTurnId) return
await window.ipc.invoke('sessions:respondToPermission', { turnId: latestTurnId, toolCallId, decision })
},
[latestTurnId],
)
const answerAskHuman = useCallback<SessionChat['answerAskHuman']>(
async (toolCallId, answer) => {
if (!latestTurnId) return
await window.ipc.invoke('sessions:setToolResult', { turnId: latestTurnId, toolCallId, result: answer })
},
[latestTurnId],
)
const stop = useCallback<SessionChat['stop']>(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],
)
}

View file

@ -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<typeof AgentLoopTurn>
function turn(overrides: Partial<Turn> = {}): 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' } })
})
})

View file

@ -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<typeof AgentLoopTurn>
type Msg = z.infer<typeof Message>
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<typeof deriveToolCallState>): 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<string, ToolCall>()
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<typeof ToolCallPart>; request: unknown }[] {
const parts = toolCallParts(turn)
const result: { toolCall: z.infer<typeof ToolCallPart>; 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<typeof deriveTurnStatus> {
return deriveTurnStatus(turn)
}
// ─── Live overlay (streaming deltas applied on top of the latest snapshot) ────
export type LiveOverlay = {
text: string
toolOutput: Record<string, string>
}
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
}
}

View file

@ -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<typeof AgentLoopTurn>
function turn(overrides: Partial<Turn> = {}): 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'])
})
})

View file

@ -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<typeof AgentLoopTurn>
type PermMeta = z.infer<typeof ToolPermissionRequestEvent>['permission']
export type SessionChatState = {
conversation: ConversationItem[]
currentAssistantMessage: string
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses: Map<string, PermissionResponse>
autoPermissionDecisions: Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
// 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<string, z.infer<typeof ToolPermissionRequestEvent>>()
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<string, PermissionResponse>()
const autoPermissionDecisions = new Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>()
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<string, z.infer<typeof AskHumanRequestEvent>>()
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',
}
}

View file

@ -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)
})
})

View file

@ -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<Listener>()
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)
}
}

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'

View file

@ -30,5 +30,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
}

View file

@ -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,
},
})

View file

@ -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/<slug>/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<SessionBusEvent>(),
res: z.null(),
},
} as const;
// ============================================================================

508
apps/x/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -23,5 +23,6 @@ onlyBuiltDependencies:
- fs-xattr
- macos-alias
- protobufjs
patchedDependencies:
'@openai/codex@0.128.0': patches/@openai__codex@0.128.0.patch