mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-15 20:05:16 +02:00
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:
parent
c1cc5a8753
commit
251a462686
24 changed files with 1901 additions and 1347 deletions
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
146
apps/x/apps/renderer/src/components/chat-conversation.tsx
Normal file
146
apps/x/apps/renderer/src/components/chat-conversation.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
83
apps/x/apps/renderer/src/hooks/useSessionChat.test.ts
Normal file
83
apps/x/apps/renderer/src/hooks/useSessionChat.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
112
apps/x/apps/renderer/src/hooks/useSessionChat.ts
Normal file
112
apps/x/apps/renderer/src/hooks/useSessionChat.ts
Normal 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],
|
||||
)
|
||||
}
|
||||
162
apps/x/apps/renderer/src/lib/agent-turn-view.test.ts
Normal file
162
apps/x/apps/renderer/src/lib/agent-turn-view.test.ts
Normal 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' } })
|
||||
})
|
||||
})
|
||||
190
apps/x/apps/renderer/src/lib/agent-turn-view.ts
Normal file
190
apps/x/apps/renderer/src/lib/agent-turn-view.ts
Normal 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
|
||||
}
|
||||
}
|
||||
123
apps/x/apps/renderer/src/lib/session-chat-state.test.ts
Normal file
123
apps/x/apps/renderer/src/lib/session-chat-state.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
109
apps/x/apps/renderer/src/lib/session-chat-state.ts
Normal file
109
apps/x/apps/renderer/src/lib/session-chat-state.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
67
apps/x/apps/renderer/src/lib/session-feed.test.ts
Normal file
67
apps/x/apps/renderer/src/lib/session-feed.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
34
apps/x/apps/renderer/src/lib/session-feed.ts
Normal file
34
apps/x/apps/renderer/src/lib/session-feed.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
1
apps/x/apps/renderer/src/test/setup.ts
Normal file
1
apps/x/apps/renderer/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
|
|
@ -30,5 +30,6 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
508
apps/x/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -23,5 +23,6 @@ onlyBuiltDependencies:
|
|||
- fs-xattr
|
||||
- macos-alias
|
||||
- protobufjs
|
||||
|
||||
patchedDependencies:
|
||||
'@openai/codex@0.128.0': patches/@openai__codex@0.128.0.patch
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue