From f5a7f21c681caa3c44bcf1bb2fc70edf531fd994 Mon Sep 17 00:00:00 2001 From: qer Date: Sat, 13 Jun 2026 22:53:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20land=20P3=20=E2=80=94=20goal=20/?= =?UTF-8?q?=20swarm=20/=20subagent=20+=20terminal=20+=20view=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the locked P3 design end-to-end: - subagent lifecycle projection (spawned→started→suspended→completed/failed) + inline Agent / AgentGroup cards; swarm progress card (multi-column) derived from swarmIndex; goal dock strip (expandable) from goal.updated; plan/goal/ swarm activation badges in the composer status line. - terminal as a view (xterm + WS terminal_* frames with since_seq replay) and a tab/view-dimension split (usePaneLayout tree + ViewGroup + SplitLayout, VSCode editor-group style), persisted to localStorage. Adds swarm-groups / subagent-goal / agent-group-turns unit tests and stub-daemon seeds. 98 tests pass; vue-tsc + oxlint clean; production build OK. Accepted by review (see reports/web-p3-acceptance.md); no blocking issues. --- apps/kimi-web/dev/stub-daemon.mjs | 219 +++++++++++++- apps/kimi-web/package.json | 2 + apps/kimi-web/src/App.vue | 4 + .../src/api/daemon/agentEventProjector.ts | 164 ++++++++++- apps/kimi-web/src/api/daemon/client.ts | 89 ++++++ apps/kimi-web/src/api/daemon/eventReducer.ts | 16 + apps/kimi-web/src/api/daemon/mappers.ts | 62 ++++ apps/kimi-web/src/api/daemon/wire.ts | 5 + apps/kimi-web/src/api/daemon/ws.ts | 108 ++++++- apps/kimi-web/src/api/types.ts | 63 ++++ apps/kimi-web/src/components/AgentCard.vue | 148 ++++++++++ apps/kimi-web/src/components/AgentGroup.vue | 98 +++++++ apps/kimi-web/src/components/ChatPane.vue | 39 ++- apps/kimi-web/src/components/Composer.vue | 83 +++++- .../src/components/ConversationPane.vue | 188 +++++++++++- apps/kimi-web/src/components/GoalStrip.vue | 196 +++++++++++++ apps/kimi-web/src/components/SplitLayout.vue | 110 +++++++ apps/kimi-web/src/components/SwarmCard.vue | 165 +++++++++++ apps/kimi-web/src/components/TabBar.vue | 1 + apps/kimi-web/src/components/Terminal.vue | 273 ++++++++++++++++++ apps/kimi-web/src/components/ViewGroup.vue | 102 +++++++ .../src/composables/messagesToTurns.ts | 57 +++- apps/kimi-web/src/composables/swarmGroups.ts | 83 ++++++ .../src/composables/useKimiWebClient.ts | 47 ++- .../kimi-web/src/composables/usePaneLayout.ts | 155 ++++++++++ apps/kimi-web/src/composables/useTerminal.ts | 142 +++++++++ apps/kimi-web/src/i18n/locales/en/sidebar.ts | 1 + apps/kimi-web/src/i18n/locales/zh/sidebar.ts | 1 + apps/kimi-web/src/types.ts | 26 +- apps/kimi-web/test/agent-group-turns.test.ts | 94 ++++++ apps/kimi-web/test/subagent-goal.test.ts | 160 ++++++++++ apps/kimi-web/test/swarm-groups.test.ts | 48 +++ pnpm-lock.yaml | 22 ++ pnpm-workspace.yaml | 3 + 34 files changed, 2930 insertions(+), 44 deletions(-) create mode 100644 apps/kimi-web/src/components/AgentCard.vue create mode 100644 apps/kimi-web/src/components/AgentGroup.vue create mode 100644 apps/kimi-web/src/components/GoalStrip.vue create mode 100644 apps/kimi-web/src/components/SplitLayout.vue create mode 100644 apps/kimi-web/src/components/SwarmCard.vue create mode 100644 apps/kimi-web/src/components/Terminal.vue create mode 100644 apps/kimi-web/src/components/ViewGroup.vue create mode 100644 apps/kimi-web/src/composables/swarmGroups.ts create mode 100644 apps/kimi-web/src/composables/usePaneLayout.ts create mode 100644 apps/kimi-web/src/composables/useTerminal.ts create mode 100644 apps/kimi-web/test/agent-group-turns.test.ts create mode 100644 apps/kimi-web/test/subagent-goal.test.ts create mode 100644 apps/kimi-web/test/swarm-groups.test.ts diff --git a/apps/kimi-web/dev/stub-daemon.mjs b/apps/kimi-web/dev/stub-daemon.mjs index ca5280236..1ccd24e92 100644 --- a/apps/kimi-web/dev/stub-daemon.mjs +++ b/apps/kimi-web/dev/stub-daemon.mjs @@ -77,7 +77,7 @@ const providers = [...seedProviders]; const models = [...seedModels]; // ---- Real OAuth singleton state ---- -let loggedIn = false; +let loggedIn = process.env.STUB_LOGGED_IN === '1'; let currentFlow = null; // { flow_id, provider, status, user_code, expires_in, interval, ... } // ---- in-memory state ---- @@ -250,7 +250,8 @@ const messages = { ], 'pr_s1_1'), mkMsg('msg_s1_5', 'ses_1', 'assistant', [ toolResult('tc_s1_b', 'File edited successfully.', false), - t('修改完成,现在跑测试确认没有回归:'), + t('修改完成,我先让两个子代理并行检查风险,然后跑测试确认没有回归:'), + toolUse('tc_s1_agent', 'agent_swarm', { description: 'Review timeout configuration change', count: 2 }), toolUse('tc_s1_c', 'bash', { command: 'pnpm --filter @kimi-code/api test --run' }), ], 'pr_s1_1'), mkMsg('msg_s1_6', 'ses_1', 'assistant', [ @@ -344,6 +345,20 @@ const tasks = { status: 'running', created_at: now(), started_at: now(), output_preview: '$ pnpm build --filter @kimi-code/api\nvite v5.2.1 building for production...', output_bytes: 128, + subagent_phase: 'working', + subagent_type: 'coder', + parent_tool_call_id: 'tc_s1_agent', + swarm_index: 1, + }, + { + id: 'task_4', session_id: 'ses_1', kind: 'subagent', description: 'Review timeout defaults', + status: 'completed', created_at: now(), started_at: now(), completed_at: now(), + output_preview: 'Default timeout remains backward compatible.', + output_bytes: 96, + subagent_phase: 'completed', + subagent_type: 'reviewer', + parent_tool_call_id: 'tc_s1_agent', + swarm_index: 2, }, { id: 'task_2', session_id: 'ses_1', kind: 'bash', description: 'eslint packages/api/src', @@ -363,6 +378,83 @@ const tasks = { ses_4: [], }; +// ---- seed active goals ---- + +const goals = { + ses_1: { + goal_id: 'goal_s1', + objective: 'Make API client timeout configurable without breaking existing callers.', + completion_criterion: 'Code accepts a timeout override, existing defaults stay compatible, and tests pass.', + status: 'active', + turns_used: 4, + tokens_used: 18400, + wall_clock_ms: 245000, + budget: { + token_budget: 50000, + remaining_tokens: 31600, + turn_budget: 8, + remaining_turns: 4, + wall_clock_budget_ms: 600000, + remaining_wall_clock_ms: 355000, + over_budget: false, + }, + }, +}; + +const terminals = {}; +const terminalSinks = new Map(); + +function terminalKey(sessionId, terminalId) { + return `${sessionId}\0${terminalId}`; +} + +function publicTerminal(record) { + const { buffer, next_seq, ...terminal } = record; + void buffer; + void next_seq; + return terminal; +} + +function terminalList(sessionId) { + terminals[sessionId] = terminals[sessionId] || []; + return terminals[sessionId]; +} + +function emitTerminalOutput(record, data) { + record.next_seq = (record.next_seq || 0) + 1; + const frame = { + type: 'terminal_output', + seq: record.next_seq, + session_id: record.session_id, + terminal_id: record.id, + timestamp: now(), + payload: { data }, + }; + record.buffer = [...(record.buffer || []), frame].slice(-200); + const sinks = terminalSinks.get(terminalKey(record.session_id, record.id)) || new Set(); + for (const ws of sinks) { + if (ws.readyState === 1) ws.send(JSON.stringify(frame)); + } +} + +function emitTerminalExit(record, exitCode) { + record.status = 'exited'; + record.exited_at = now(); + record.exit_code = exitCode; + const frame = { + type: 'terminal_exit', + session_id: record.session_id, + terminal_id: record.id, + timestamp: now(), + payload: { exit_code: exitCode }, + }; + record.buffer = [...(record.buffer || []), frame].slice(-200); + const sinks = terminalSinks.get(terminalKey(record.session_id, record.id)) || new Set(); + for (const ws of sinks) { + if (ws.readyState === 1) ws.send(JSON.stringify(frame)); + } +} + // ---- sequence counters ---- const seqBySession = { ses_1: 8, ses_2: 5, ses_3: 2, ses_4: 0 }; @@ -385,6 +477,22 @@ function broadcast(type, sessionId, payload) { return seq; } +function sendEvent(ws, type, sessionId, payload) { + const seq = (seqBySession[sessionId] = (seqBySession[sessionId] || 0) + 1); + const session = sessions.find((s) => s.id === sessionId); + if (session) session.last_seq = seq; + if (ws.readyState === 1) { + ws.send(JSON.stringify({ type, seq, session_id: sessionId, timestamp: now(), payload })); + } + return seq; +} + +function sendGoalSnapshots(ws, sessionIds) { + for (const sessionId of sessionIds) { + if (goals[sessionId]) sendEvent(ws, 'event.goal.updated', sessionId, { snapshot: goals[sessionId] }); + } +} + // ---- raw mode flag ---- // Set STUB_RAW_EVENTS=1 to emit raw agent-core events instead of projected event.* frames. const RAW_EVENTS_MODE = process.env.STUB_RAW_EVENTS === '1'; @@ -1159,6 +1267,43 @@ const server = http.createServer((req, res) => { return res.end(ok({ cancelled: true })); } + // ---- terminals ---- + if (seg[0] === 'sessions' && seg[2] === 'terminals' && seg.length === 3 && method === 'GET') { + return res.end(ok({ items: terminalList(sid).map(publicTerminal) })); + } + if (seg[0] === 'sessions' && seg[2] === 'terminals' && seg.length === 3 && method === 'POST') { + const session = sessions.find((s) => s.id === sid); + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + const b = json(); + const record = { + id: ulid('term_'), + session_id: sid, + cwd: session.metadata?.cwd || '/tmp', + shell: b.shell || '/bin/zsh', + cols: b.cols || 80, + rows: b.rows || 24, + status: 'running', + created_at: now(), + buffer: [], + next_seq: 0, + }; + terminalList(sid).push(record); + return res.end(ok(publicTerminal(record))); + } + if (seg[0] === 'sessions' && seg[2] === 'terminals' && seg.length === 4 && method === 'GET') { + const termId = seg[3]; + const record = terminalList(sid).find((item) => item.id === termId); + if (!record) return res.end(fail(40407, `terminal ${termId} does not exist`)); + return res.end(ok(publicTerminal(record))); + } + if (seg[0] === 'sessions' && seg[2] === 'terminals' && seg.length === 4 && method === 'POST' && seg[3].endsWith(':close')) { + const termId = seg[3].replace(':close', ''); + const record = terminalList(sid).find((item) => item.id === termId); + if (!record) return res.end(fail(40407, `terminal ${termId} does not exist`)); + if (record.status !== 'exited') emitTerminalExit(record, 0); + return res.end(ok({ closed: true })); + } + // ---- prompts ---- if (seg[0] === 'sessions' && seg[2] === 'prompts' && seg.length === 3 && method === 'POST') { const b = json(); @@ -1844,24 +1989,28 @@ wss.on('connection', (ws) => { try { m = JSON.parse(String(raw)); } catch { return; } if (m.type === 'client_hello') { + const acceptedSubscriptions = m.payload?.subscriptions || []; ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: { - accepted_subscriptions: m.payload?.subscriptions || [], + accepted_subscriptions: acceptedSubscriptions, resync_required: [], }, })); + sendGoalSnapshots(ws, acceptedSubscriptions); } if (m.type === 'subscribe') { + const accepted = m.payload?.session_ids || []; ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: { - accepted: m.payload?.session_ids || [], + accepted, not_found: [], resync_required: [], }, })); + sendGoalSnapshots(ws, accepted); } if (m.type === 'unsubscribe') { @@ -1875,6 +2024,67 @@ wss.on('connection', (ws) => { })); } + if (m.type === 'terminal_attach') { + const { session_id, terminal_id, since_seq } = m.payload || {}; + const record = terminalList(session_id).find((item) => item.id === terminal_id); + if (!record) { + ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 40407, msg: 'terminal not found', payload: {} })); + return; + } + const key = terminalKey(session_id, terminal_id); + const sinks = terminalSinks.get(key) || new Set(); + sinks.add(ws); + terminalSinks.set(key, sinks); + const replay = (record.buffer || []).filter((frame) => frame.type !== 'terminal_output' || frame.seq > (since_seq || 0)); + for (const frame of replay) { + if (ws.readyState === 1) ws.send(JSON.stringify(frame)); + } + if ((record.buffer || []).length === 0 && record.status === 'running') { + emitTerminalOutput(record, `stub terminal ${record.id}\r\n${record.cwd} $ `); + } + ws.send(JSON.stringify({ + type: 'ack', id: m.id, code: 0, msg: 'success', + payload: { attached: true, replayed: replay.length }, + })); + } + + if (m.type === 'terminal_detach') { + const { session_id, terminal_id } = m.payload || {}; + terminalSinks.get(terminalKey(session_id, terminal_id))?.delete(ws); + ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: { detached: true } })); + } + + if (m.type === 'terminal_input') { + const { session_id, terminal_id, data } = m.payload || {}; + const record = terminalList(session_id).find((item) => item.id === terminal_id); + if (!record || record.status === 'exited') { + ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 40407, msg: 'terminal not found', payload: {} })); + return; + } + emitTerminalOutput(record, data); + if (typeof data === 'string' && (data.includes('\r') || data.includes('\n'))) { + emitTerminalOutput(record, `${record.cwd} $ `); + } + ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: { accepted: true } })); + } + + if (m.type === 'terminal_resize') { + const { session_id, terminal_id, cols, rows } = m.payload || {}; + const record = terminalList(session_id).find((item) => item.id === terminal_id); + if (record) { + record.cols = cols || record.cols; + record.rows = rows || record.rows; + } + ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: { resized: true } })); + } + + if (m.type === 'terminal_close') { + const { session_id, terminal_id } = m.payload || {}; + const record = terminalList(session_id).find((item) => item.id === terminal_id); + if (record && record.status !== 'exited') emitTerminalExit(record, 0); + ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: { closed: true } })); + } + if (m.type === 'pong') { // heartbeat response — no-op } @@ -1890,6 +2100,7 @@ wss.on('connection', (ws) => { ws.on('close', () => { clearInterval(ping); sockets.delete(ws); + for (const sinks of terminalSinks.values()) sinks.delete(ws); }); }); diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index 55e0b257e..c9830837d 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -13,6 +13,8 @@ }, "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "markstream-vue": "1.0.1-beta.5", "shiki": "^4.2.0", "stream-markdown": "^0.0.15", diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index d9b226b60..c0a2d61e4 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -606,6 +606,7 @@ function handleCreateSessionInWorkspace(workspaceId: string): void { :mobile="isMobile" :modern="client.theme.value === 'modern' || client.theme.value === 'kimi'" :turns="client.turns.value" + :session-id="client.activeSessionId.value" :approvals="client.pendingApprovals.value" :changes="client.changes.value" :git-info="client.gitInfo.value" @@ -616,6 +617,9 @@ function handleCreateSessionInWorkspace(workspaceId: string): void { :clear-file-diff="client.clearFileDiff" :tasks="client.tasks.value" :todos="client.todos.value" + :goal="client.goal.value" + :swarms="client.swarms.value" + :activation-badges="client.activationBadges.value" :status="client.status.value" :thinking="client.thinking.value" :plan-mode="client.planMode.value" diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 4d3a54049..1471ff64a 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -16,7 +16,15 @@ // const appEvents = projector.project(rawType, payload, sessionId); // // call reset() when re-subscribing / resyncing a session -import type { AppEvent, AppInFlightTurn, AppMessage, AppMessageContent, AppSessionUsage } from '../types'; +import type { + AppEvent, + AppGoal, + AppInFlightTurn, + AppMessage, + AppMessageContent, + AppSessionUsage, + AppTask, +} from '../types'; import { i18n } from '../../i18n'; import { toAppMessageContent } from './mappers'; import type { WireMessageContent } from './wire'; @@ -83,6 +91,10 @@ interface SessionState { // In-memory message log (mirrors daemon message-log.ts) messages: AppMessage[]; + + // Subagent lifecycle deltas after spawned only carry subagentId. Keep the + // spawned metadata here so later updates can replace the full AppTask. + subagentMeta: Map; } function createSessionState(): SessionState { @@ -102,9 +114,76 @@ function createSessionState(): SessionState { turnCount: 0, model: '', messages: [], + subagentMeta: new Map(), }; } +function stringField(source: Record, key: string): string | undefined { + const value = source[key]; + return typeof value === 'string' ? value : undefined; +} + +function numberField(source: Record, key: string): number | undefined { + const value = source[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function nullableNumberField(source: Record, key: string): number | null { + const value = source[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function mapGoalSnapshot(snapshot: unknown): AppGoal | null { + if (!snapshot || typeof snapshot !== 'object') return null; + const s = snapshot as Record; + const budgetRaw = s['budget']; + const budget = budgetRaw && typeof budgetRaw === 'object' ? budgetRaw as Record : {}; + const status = stringField(s, 'status'); + if (status !== 'active' && status !== 'paused' && status !== 'blocked' && status !== 'complete') return null; + const goalId = stringField(s, 'goalId') ?? stringField(s, 'goal_id') ?? 'goal'; + const objective = stringField(s, 'objective') ?? ''; + return { + goalId, + objective, + completionCriterion: stringField(s, 'completionCriterion') ?? stringField(s, 'completion_criterion'), + status, + turnsUsed: numberField(s, 'turnsUsed') ?? numberField(s, 'turns_used') ?? 0, + tokensUsed: numberField(s, 'tokensUsed') ?? numberField(s, 'tokens_used') ?? 0, + wallClockMs: numberField(s, 'wallClockMs') ?? numberField(s, 'wall_clock_ms') ?? 0, + terminalReason: stringField(s, 'terminalReason') ?? stringField(s, 'terminal_reason'), + budget: { + tokenBudget: nullableNumberField(budget, 'tokenBudget') ?? nullableNumberField(budget, 'token_budget'), + remainingTokens: nullableNumberField(budget, 'remainingTokens') ?? nullableNumberField(budget, 'remaining_tokens'), + turnBudget: nullableNumberField(budget, 'turnBudget') ?? nullableNumberField(budget, 'turn_budget'), + remainingTurns: nullableNumberField(budget, 'remainingTurns') ?? nullableNumberField(budget, 'remaining_turns'), + wallClockBudgetMs: nullableNumberField(budget, 'wallClockBudgetMs') ?? nullableNumberField(budget, 'wall_clock_budget_ms'), + remainingWallClockMs: nullableNumberField(budget, 'remainingWallClockMs') ?? nullableNumberField(budget, 'remaining_wall_clock_ms'), + overBudget: budget['overBudget'] === true || budget['over_budget'] === true, + }, + }; +} + +function patchSubagent( + state: SessionState, + sessionId: string, + subagentId: unknown, + patch: Partial, +): AppTask | null { + if (typeof subagentId !== 'string' || subagentId.length === 0) return null; + const prev = state.subagentMeta.get(subagentId) ?? { + id: subagentId, + sessionId, + kind: 'subagent', + description: 'subagent', + status: 'running', + createdAt: new Date().toISOString(), + subagentPhase: 'queued', + } satisfies AppTask; + const next: AppTask = { ...prev, ...patch, id: subagentId, sessionId, kind: 'subagent' }; + state.subagentMeta.set(subagentId, next); + return next; +} + // --------------------------------------------------------------------------- // Message-log helpers (inlined; mirrors message-log.ts) // --------------------------------------------------------------------------- @@ -175,7 +254,7 @@ function appendAssistantDelta( ): number { const msg = state.messages.find((m) => m.id === messageId); if (!msg) return -1; - const last = msg.content[msg.content.length - 1]; + const last = msg.content.at(-1); if (last && last.type === kind) { if (kind === 'text') (last as { type: 'text'; text: string }).text += delta; else (last as { type: 'thinking'; thinking: string }).thinking += delta; @@ -347,9 +426,9 @@ export function createAgentProjector(): AgentProjector { ): AppEvent[] { try { return _project(rawType, payload, sessionId, meta); - } catch (err) { + } catch (error) { // Defensive: log but never crash the caller - console.error('[agentProjector] Error projecting event:', rawType, err instanceof Error ? err.message : err); + console.error('[agentProjector] Error projecting event:', rawType, error instanceof Error ? error.message : error); return []; } } @@ -704,39 +783,82 @@ export function createAgentProjector(): AgentProjector { // ----------------------------------------------------------------------- case 'subagent.spawned': { + const taskId = typeof p?.subagentId === 'string' && p.subagentId.length > 0 ? p.subagentId : ulid('task_'); + const task: AppTask = { + id: taskId, + sessionId, + kind: 'subagent', + description: typeof p?.description === 'string' ? p.description : p?.subagentName ?? 'subagent', + status: 'running', + createdAt: new Date().toISOString(), + subagentPhase: 'queued', + subagentType: typeof p?.subagentName === 'string' ? p.subagentName : undefined, + parentToolCallId: typeof p?.parentToolCallId === 'string' ? p.parentToolCallId : undefined, + swarmIndex: typeof p?.swarmIndex === 'number' ? p.swarmIndex : undefined, + }; + s.subagentMeta.set(task.id, task); out.push({ type: 'taskCreated', sessionId, - task: { - id: p?.subagentId ?? ulid('task_'), - sessionId, - kind: 'subagent', - description: p?.subagentName ?? 'subagent', - status: 'running', - createdAt: new Date().toISOString(), - }, + task, }); break; } + case 'subagent.started': { + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'working', + status: 'running', + startedAt: new Date().toISOString(), + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + break; + } + + case 'subagent.suspended': { + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'suspended', + status: 'running', + suspendedReason: typeof p?.reason === 'string' ? p.reason : undefined, + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + break; + } + case 'subagent.completed': { + const outputPreview = typeof p?.resultSummary === 'string' ? p.resultSummary : undefined; + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'completed', + status: 'completed', + completedAt: new Date().toISOString(), + outputPreview, + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); out.push({ type: 'taskCompleted', sessionId, taskId: p?.subagentId ?? '', status: 'completed', - outputPreview: typeof p?.resultSummary === 'string' ? p.resultSummary : undefined, + outputPreview, }); break; } case 'subagent.failed': { + const outputPreview = typeof p?.error === 'string' ? p.error : undefined; + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'failed', + status: 'failed', + completedAt: new Date().toISOString(), + outputPreview, + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); out.push({ type: 'taskCompleted', sessionId, taskId: p?.subagentId ?? '', status: 'failed', - outputPreview: typeof p?.error === 'string' ? p.error : undefined, + outputPreview, }); break; } @@ -854,11 +976,20 @@ export function createAgentProjector(): AgentProjector { break; } + case 'goal.updated': { + const goal = mapGoalSnapshot(p?.snapshot ?? null); + out.push({ + type: 'goalUpdated', + sessionId, + goal: goal?.status === 'complete' ? null : goal, + }); + break; + } + // ----------------------------------------------------------------------- // Explicitly known but not projected case 'compaction.blocked': case 'cron.fired': - case 'goal.updated': case 'hook.result': case 'mcp.server.status': case 'skill.activated': @@ -929,9 +1060,12 @@ const KNOWN_AGENT_CORE_TYPES = new Set([ 'compaction.started', 'compaction.completed', 'compaction.cancelled', + 'goal.updated', 'error', 'warning', 'subagent.spawned', + 'subagent.started', + 'subagent.suspended', 'subagent.completed', 'subagent.failed', 'background.task.started', diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 54e9b6be5..b642bda62 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -16,6 +16,7 @@ import type { AppSessionStatus, AppTask, AppTaskStatus, + AppTerminal, AppWorkspace, ApprovalResponse, FsBrowseResult, @@ -185,6 +186,34 @@ interface WireDiffResult { diff: string; } +interface WireTerminal { + id: string; + session_id: string; + cwd: string; + shell: string; + cols: number; + rows: number; + status: 'running' | 'exited'; + created_at: string; + exited_at?: string; + exit_code?: number | null; +} + +function toAppTerminal(data: WireTerminal): AppTerminal { + return { + id: data.id, + sessionId: data.session_id, + cwd: data.cwd, + shell: data.shell, + cols: data.cols, + rows: data.rows, + status: data.status, + createdAt: data.created_at, + exitedAt: data.exited_at, + exitCode: data.exit_code, + }; +} + /** * historyCompacted reasons caused by compaction itself. These do NOT trigger a * snapshot reload: the client keeps the visible scrollback and renders a @@ -553,6 +582,43 @@ export class DaemonKimiWebApi implements KimiWebApi { return data; } + async listTerminals(sessionId: string): Promise { + const data = await this.http.get<{ items: WireTerminal[] }>( + `/sessions/${encodeURIComponent(sessionId)}/terminals`, + ); + return data.items.map(toAppTerminal); + } + + async createTerminal( + sessionId: string, + input: { cwd?: string; shell?: string; cols?: number; rows?: number } = {}, + ): Promise { + const body: Record = { + cwd: input.cwd, + shell: input.shell, + cols: input.cols, + rows: input.rows, + }; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/terminals`, + body, + ); + return toAppTerminal(data); + } + + async getTerminal(sessionId: string, terminalId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/terminals/${encodeURIComponent(terminalId)}`, + ); + return toAppTerminal(data); + } + + async closeTerminal(sessionId: string, terminalId: string): Promise<{ closed: true }> { + return this.http.post<{ closed: true }>( + `/sessions/${encodeURIComponent(sessionId)}/terminals/${encodeURIComponent(terminalId)}:close`, + ); + } + // ------------------------------------------------------------------------- // Skills — session-scoped slash-invocable skills // GET /sessions/{id}/skills → { skills: WireSkillDescriptor[] } @@ -1055,6 +1121,14 @@ export class DaemonKimiWebApi implements KimiWebApi { onError: (code: number, msg: string, fatal: boolean) => { handlers.onError(code, msg, fatal); }, + + onTerminalOutput: (sessionId, terminalId, data, seq) => { + handlers.onTerminalOutput?.(sessionId, terminalId, data, seq); + }, + + onTerminalExit: (sessionId, terminalId, exitCode) => { + handlers.onTerminalExit?.(sessionId, terminalId, exitCode); + }, }); socket.connect(); @@ -1097,6 +1171,21 @@ export class DaemonKimiWebApi implements KimiWebApi { abort(sessionId: string, promptId: string): void { socket.abort(sessionId, promptId); }, + terminalAttach(sessionId: string, terminalId: string, sinceSeq?: number): void { + socket.terminalAttach(sessionId, terminalId, sinceSeq); + }, + terminalInput(sessionId: string, terminalId: string, data: string): void { + socket.terminalInput(sessionId, terminalId, data); + }, + terminalResize(sessionId: string, terminalId: string, cols: number, rows: number): void { + socket.terminalResize(sessionId, terminalId, cols, rows); + }, + terminalDetach(sessionId: string, terminalId: string): void { + socket.terminalDetach(sessionId, terminalId); + }, + terminalClose(sessionId: string, terminalId: string): void { + socket.terminalClose(sessionId, terminalId); + }, close(): void { socket.close(); }, diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index d0911d358..06fe2368a 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -11,6 +11,7 @@ import type { AppApprovalRequest, AppEvent, + AppGoal, AppMessage, AppMessageContent, AppWarning, @@ -43,6 +44,7 @@ export interface KimiClientState { approvalsBySession: Record; questionsBySession: Record; tasksBySession: Record; + goalBySession: Record; lastSeqBySession: Record; compactionBySession: Record; warnings: AppWarning[]; @@ -56,6 +58,7 @@ export function createInitialState(): KimiClientState { approvalsBySession: {}, questionsBySession: {}, tasksBySession: {}, + goalBySession: {}, lastSeqBySession: {}, compactionBySession: {}, warnings: [], @@ -74,6 +77,7 @@ function cloneState(s: KimiClientState): KimiClientState { approvalsBySession: { ...s.approvalsBySession }, questionsBySession: { ...s.questionsBySession }, tasksBySession: { ...s.tasksBySession }, + goalBySession: { ...s.goalBySession }, lastSeqBySession: { ...s.lastSeqBySession }, compactionBySession: { ...s.compactionBySession }, warnings: [...s.warnings], @@ -163,6 +167,7 @@ export function reduceAppEvent( next.sessions = next.sessions.filter((s) => s.id !== id); delete next.messagesBySession[id]; delete next.tasksBySession[id]; + delete next.goalBySession[id]; delete next.approvalsBySession[id]; delete next.questionsBySession[id]; delete next.lastSeqBySession[id]; @@ -437,6 +442,17 @@ export function reduceAppEvent( break; } + // ------------------------------------------------------------------------- + case 'goalUpdated': { + const sid = event.sessionId; + if (event.goal === null || event.goal.status === 'complete') { + delete next.goalBySession[sid]; + } else { + next.goalBySession[sid] = event.goal; + } + break; + } + // ------------------------------------------------------------------------- case 'unknown': { // Distinguish no-op known events (sentinel _noop) from agent errors/warnings diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index 8b077193f..3a820307d 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -5,6 +5,7 @@ import type { AppApprovalRequest, AppEvent, + AppGoal, AppModel, AppProvider, FsEntry, @@ -357,6 +358,11 @@ export function toAppTask(wire: WireBackgroundTask): AppTask { completedAt: wire.completed_at, outputPreview: wire.output_preview, outputBytes: wire.output_bytes, + subagentPhase: wire.subagent_phase, + subagentType: wire.subagent_type, + parentToolCallId: wire.parent_tool_call_id, + suspendedReason: wire.suspended_reason, + swarmIndex: wire.swarm_index, // outputLines starts undefined; populated by eventReducer via task.progress events }; } @@ -386,6 +392,53 @@ export function toAppFsEntry(wire: WireFsEntry): FsEntry { // WireEvent → AppEvent // --------------------------------------------------------------------------- +function recordString(source: Record, key: string): string | undefined { + const value = source[key]; + return typeof value === 'string' ? value : undefined; +} + +function recordNumber(source: Record, key: string): number | undefined { + const value = source[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function recordNullableNumber(source: Record, key: string): number | null { + const value = source[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function toAppGoal(snapshot: unknown): AppGoal | null { + if (!snapshot || typeof snapshot !== 'object') return null; + const source = snapshot as Record; + const status = recordString(source, 'status'); + if (status !== 'active' && status !== 'paused' && status !== 'blocked' && status !== 'complete') { + return null; + } + + const budgetRaw = source['budget']; + const budget = budgetRaw && typeof budgetRaw === 'object' ? budgetRaw as Record : {}; + + return { + goalId: recordString(source, 'goalId') ?? recordString(source, 'goal_id') ?? 'goal', + objective: recordString(source, 'objective') ?? '', + completionCriterion: recordString(source, 'completionCriterion') ?? recordString(source, 'completion_criterion'), + status, + turnsUsed: recordNumber(source, 'turnsUsed') ?? recordNumber(source, 'turns_used') ?? 0, + tokensUsed: recordNumber(source, 'tokensUsed') ?? recordNumber(source, 'tokens_used') ?? 0, + wallClockMs: recordNumber(source, 'wallClockMs') ?? recordNumber(source, 'wall_clock_ms') ?? 0, + terminalReason: recordString(source, 'terminalReason') ?? recordString(source, 'terminal_reason'), + budget: { + tokenBudget: recordNullableNumber(budget, 'tokenBudget') ?? recordNullableNumber(budget, 'token_budget'), + remainingTokens: recordNullableNumber(budget, 'remainingTokens') ?? recordNullableNumber(budget, 'remaining_tokens'), + turnBudget: recordNullableNumber(budget, 'turnBudget') ?? recordNullableNumber(budget, 'turn_budget'), + remainingTurns: recordNullableNumber(budget, 'remainingTurns') ?? recordNullableNumber(budget, 'remaining_turns'), + wallClockBudgetMs: recordNullableNumber(budget, 'wallClockBudgetMs') ?? recordNullableNumber(budget, 'wall_clock_budget_ms'), + remainingWallClockMs: recordNullableNumber(budget, 'remainingWallClockMs') ?? recordNullableNumber(budget, 'remaining_wall_clock_ms'), + overBudget: budget['overBudget'] === true || budget['over_budget'] === true, + }, + }; +} + /** * Map a WireEvent to an AppEvent. * @@ -446,6 +499,15 @@ export function toAppEvent(wire: WireEvent): AppEvent { summaryMessageId: w.payload.summary_message_id, }; + case 'event.goal.updated': { + const goal = toAppGoal(w.payload.snapshot ?? null); + return { + type: 'goalUpdated', + sessionId: w.session_id, + goal: goal?.status === 'complete' ? null : goal, + }; + } + // ----- Message lifecycle ----- case 'event.message.created': return { type: 'messageCreated', message: toAppMessage(w.payload.message) }; diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index cd1897b6c..43ad86d7b 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -279,6 +279,11 @@ export interface WireBackgroundTask { completed_at?: string; output_preview?: string; output_bytes?: number; + subagent_phase?: 'queued' | 'working' | 'suspended' | 'completed' | 'failed'; + subagent_type?: string; + parent_tool_call_id?: string; + suspended_reason?: string; + swarm_index?: number; } // --------------------------------------------------------------------------- diff --git a/apps/kimi-web/src/api/daemon/ws.ts b/apps/kimi-web/src/api/daemon/ws.ts index f7fbae85e..b59e2f238 100644 --- a/apps/kimi-web/src/api/daemon/ws.ts +++ b/apps/kimi-web/src/api/daemon/ws.ts @@ -35,6 +35,8 @@ export interface DaemonEventSocketHandlers { onConnectionState(connected: boolean): void; /** Called on error frames or JSON parse failures */ onError(code: number, msg: string, fatal: boolean): void; + onTerminalOutput?(sessionId: string, terminalId: string, data: string, seq: number): void; + onTerminalExit?(sessionId: string, terminalId: string, exitCode: number | null): void; } // --------------------------------------------------------------------------- @@ -52,6 +54,12 @@ interface PendingSubscription { cursor: SessionCursor; } +interface TerminalAttachment { + sessionId: string; + terminalId: string; + lastSeq: number; +} + export class DaemonEventSocket { private ws: WebSocket | null = null; private connected = false; @@ -62,6 +70,7 @@ export class DaemonEventSocket { /** subscriptions queued while not yet connected */ private readonly pendingSubscriptions: PendingSubscription[] = []; + private readonly terminalAttachments = new Map(); private msgSeq = 0; @@ -93,9 +102,9 @@ export class DaemonEventSocket { const frame = JSON.parse(String(ev.data)) as WireServerFrame; traceWsIn(frame); this.handleFrame(frame); - } catch (err) { - traceWsLifecycle('parse-error', { error: String(err) }); - this.handlers.onError(0, `Failed to parse WS frame: ${String(err)}`, false); + } catch (error) { + traceWsLifecycle('parse-error', { error: String(error) }); + this.handlers.onError(0, `Failed to parse WS frame: ${String(error)}`, false); } }; @@ -173,6 +182,53 @@ export class DaemonEventSocket { }); } + terminalAttach(sessionId: string, terminalId: string, sinceSeq?: number): void { + const key = terminalKey(sessionId, terminalId); + const previous = this.terminalAttachments.get(key); + const lastSeq = sinceSeq ?? previous?.lastSeq ?? 0; + this.terminalAttachments.set(key, { sessionId, terminalId, lastSeq }); + if (!this.connected || !this.ws) return; + this.sendTerminalAttach(sessionId, terminalId, lastSeq); + } + + terminalInput(sessionId: string, terminalId: string, data: string): void { + if (!this.connected || !this.ws) return; + this.send({ + type: 'terminal_input', + id: this.nextId(), + payload: { session_id: sessionId, terminal_id: terminalId, data }, + }); + } + + terminalResize(sessionId: string, terminalId: string, cols: number, rows: number): void { + if (!this.connected || !this.ws) return; + this.send({ + type: 'terminal_resize', + id: this.nextId(), + payload: { session_id: sessionId, terminal_id: terminalId, cols, rows }, + }); + } + + terminalDetach(sessionId: string, terminalId: string): void { + this.terminalAttachments.delete(terminalKey(sessionId, terminalId)); + if (!this.connected || !this.ws) return; + this.send({ + type: 'terminal_detach', + id: this.nextId(), + payload: { session_id: sessionId, terminal_id: terminalId }, + }); + } + + terminalClose(sessionId: string, terminalId: string): void { + this.terminalAttachments.delete(terminalKey(sessionId, terminalId)); + if (!this.connected || !this.ws) return; + this.send({ + type: 'terminal_close', + id: this.nextId(), + payload: { session_id: sessionId, terminal_id: terminalId }, + }); + } + /** Close the socket. Stops reconnect attempts. */ close(): void { this.closed = true; @@ -239,6 +295,32 @@ export class DaemonEventSocket { // ack frames are fire-and-forget for now (no request tracking) break; + case 'terminal_output': { + const sessionId = frame.session_id as string; + const terminalId = frame.terminal_id as string; + const seq = frame.seq as number; + const key = terminalKey(sessionId, terminalId); + const existing = this.terminalAttachments.get(key); + if (existing) { + this.terminalAttachments.set(key, { + ...existing, + lastSeq: Math.max(existing.lastSeq, seq), + }); + } + const data = typeof frame.payload?.data === 'string' ? frame.payload.data : ''; + this.handlers.onTerminalOutput?.(sessionId, terminalId, data, seq); + break; + } + + case 'terminal_exit': { + const sessionId = frame.session_id as string; + const terminalId = frame.terminal_id as string; + const rawExitCode = frame.payload?.exit_code; + const exitCode = typeof rawExitCode === 'number' ? rawExitCode : null; + this.handlers.onTerminalExit?.(sessionId, terminalId, exitCode); + break; + } + default: { // Track the per-session cursor from durable event envelopes so the // reconnect handshake resumes from the freshest watermark. Volatile @@ -321,6 +403,10 @@ export class DaemonEventSocket { cursors, }, }); + + for (const attachment of this.terminalAttachments.values()) { + this.sendTerminalAttach(attachment.sessionId, attachment.terminalId, attachment.lastSeq); + } } private sendSubscribe(sessionIds: string[], cursors: Record): void { @@ -334,6 +420,18 @@ export class DaemonEventSocket { }); } + private sendTerminalAttach(sessionId: string, terminalId: string, sinceSeq: number): void { + this.send({ + type: 'terminal_attach', + id: this.nextId(), + payload: { + session_id: sessionId, + terminal_id: terminalId, + since_seq: sinceSeq > 0 ? sinceSeq : undefined, + }, + }); + } + /** * Advance the tracked cursor from a durable event envelope (seq + epoch). * Volatile frames are skipped (their seq is the same watermark, and a @@ -365,3 +463,7 @@ export class DaemonEventSocket { return `c_${++this.msgSeq}`; } } + +function terminalKey(sessionId: string, terminalId: string): string { + return `${sessionId}\0${terminalId}`; +} diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 43ed16089..7a536efaf 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -276,6 +276,7 @@ export interface QuestionResponse { // --------------------------------------------------------------------------- export type AppTaskStatus = 'running' | 'completed' | 'failed' | 'cancelled'; +export type AppSubagentPhase = 'queued' | 'working' | 'suspended' | 'completed' | 'failed'; export interface AppTask { id: string; @@ -289,6 +290,56 @@ export interface AppTask { outputPreview?: string; outputBytes?: number; outputLines?: string[]; // accumulated by eventReducer from task.progress chunks + subagentPhase?: AppSubagentPhase; + subagentType?: string; + parentToolCallId?: string; + suspendedReason?: string; + swarmIndex?: number; +} + +// --------------------------------------------------------------------------- +// Goal +// --------------------------------------------------------------------------- + +export type AppGoalStatus = 'active' | 'paused' | 'blocked' | 'complete'; + +export interface AppGoal { + goalId: string; + objective: string; + completionCriterion?: string; + status: AppGoalStatus; + turnsUsed: number; + tokensUsed: number; + wallClockMs: number; + terminalReason?: string; + budget: { + tokenBudget: number | null; + remainingTokens: number | null; + turnBudget: number | null; + remainingTurns: number | null; + wallClockBudgetMs: number | null; + remainingWallClockMs: number | null; + overBudget: boolean; + }; +} + +// --------------------------------------------------------------------------- +// Terminal +// --------------------------------------------------------------------------- + +export type AppTerminalStatus = 'running' | 'exited'; + +export interface AppTerminal { + id: string; + sessionId: string; + cwd: string; + shell: string; + cols: number; + rows: number; + status: AppTerminalStatus; + createdAt: string; + exitedAt?: string; + exitCode?: number | null; } // --------------------------------------------------------------------------- @@ -340,6 +391,7 @@ export type AppEvent = | { type: 'taskCreated'; sessionId: string; task: AppTask } | { type: 'taskProgress'; sessionId: string; taskId: string; outputChunk: string; stream: 'stdout' | 'stderr' } | { type: 'taskCompleted'; sessionId: string; taskId: string; status: AppTaskStatus; outputPreview?: string; outputBytes?: number } + | { type: 'goalUpdated'; sessionId: string; goal: AppGoal | null } | { type: 'unknown'; raw: unknown }; // --------------------------------------------------------------------------- @@ -390,6 +442,8 @@ export interface KimiEventHandlers { onResync(sessionId: string, currentSeq: number, epoch?: string): void; onError(code: number, msg: string, fatal: boolean): void; onConnectionChange(connected: boolean): void; + onTerminalOutput?(sessionId: string, terminalId: string, data: string, seq: number): void; + onTerminalExit?(sessionId: string, terminalId: string, exitCode: number | null): void; } export interface KimiEventConnection { @@ -409,6 +463,11 @@ export interface KimiEventConnection { */ seedSnapshot(sessionId: string, snapshot: AppSessionSnapshot): void; abort(sessionId: string, promptId: string): void; + terminalAttach(sessionId: string, terminalId: string, sinceSeq?: number): void; + terminalInput(sessionId: string, terminalId: string, data: string): void; + terminalResize(sessionId: string, terminalId: string, cols: number, rows: number): void; + terminalDetach(sessionId: string, terminalId: string): void; + terminalClose(sessionId: string, terminalId: string): void; close(): void; } @@ -488,6 +547,10 @@ export interface KimiWebApi { listTasks(sessionId: string, status?: AppTaskStatus): Promise; getTask(sessionId: string, taskId: string, input?: { withOutput?: boolean; outputBytes?: number }): Promise; cancelTask(sessionId: string, taskId: string): Promise<{ cancelled: true }>; + listTerminals(sessionId: string): Promise; + createTerminal(sessionId: string, input?: { cwd?: string; shell?: string; cols?: number; rows?: number }): Promise; + getTerminal(sessionId: string, terminalId: string): Promise; + closeTerminal(sessionId: string, terminalId: string): Promise<{ closed: true }>; listDirectory(sessionId: string, input: { path?: string; depth?: number; includeGitStatus?: boolean }): Promise<{ items: FsEntry[]; childrenByPath?: Record; truncated: boolean }>; readFile(sessionId: string, input: { path: string; offset?: number; length?: number }): Promise<{ path: string; content: string; encoding: 'utf-8' | 'base64'; size: number; truncated: boolean; etag: string; mime: string; languageId?: string; lineCount?: number; isBinary: boolean }>; searchFiles(sessionId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>; diff --git a/apps/kimi-web/src/components/AgentCard.vue b/apps/kimi-web/src/components/AgentCard.vue new file mode 100644 index 000000000..530fbbabf --- /dev/null +++ b/apps/kimi-web/src/components/AgentCard.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/apps/kimi-web/src/components/AgentGroup.vue b/apps/kimi-web/src/components/AgentGroup.vue new file mode 100644 index 000000000..8dc3e7b86 --- /dev/null +++ b/apps/kimi-web/src/components/AgentGroup.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/apps/kimi-web/src/components/ChatPane.vue b/apps/kimi-web/src/components/ChatPane.vue index 2bcd56cbc..397ee92f1 100644 --- a/apps/kimi-web/src/components/ChatPane.vue +++ b/apps/kimi-web/src/components/ChatPane.vue @@ -3,13 +3,14 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import type { ChatTurn, ApprovalBlock, FilePreviewRequest, ToolMedia, TurnBlock } from '../types'; - -const { t } = useI18n(); import ToolCall from './ToolCall.vue'; import Markdown from './Markdown.vue'; import ThinkingBlock from './ThinkingBlock.vue'; import ActivityNotice from './ActivityNotice.vue'; +import AgentCard from './AgentCard.vue'; +import AgentGroup from './AgentGroup.vue'; +const { t } = useI18n(); const MOON_FRAMES = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']; const MOON_INTERVAL_MS = 120; @@ -94,7 +95,7 @@ const childBubble = computed(() => props.bubble || props.mobile); // every other turn renders statically. const streamingTurnId = computed(() => { if (!props.running || props.turns.length === 0) return null; - const last = props.turns[props.turns.length - 1]!; + const last = props.turns.at(-1)!; return last.role === 'assistant' ? last.id : null; }); @@ -147,6 +148,14 @@ function turnPlainText(turn: ChatTurn): string { else if (blk.kind === 'text' && blk.text) parts.push(blk.text); else if (blk.kind === 'tool' && blk.tool.output && blk.tool.output.length > 0) { parts.push(`[${blk.tool.name}]\n${blk.tool.output.join('\n')}`); + } else if (blk.kind === 'agent') { + parts.push(`[agent] ${blk.member.name} - ${blk.member.phase}${blk.member.summary ? `\n${blk.member.summary}` : ''}`); + } else if (blk.kind === 'agentGroup') { + parts.push( + `[agents]\n${blk.members + .map((member) => `- ${member.name}: ${member.phase}${member.summary ? ` - ${member.summary}` : ''}`) + .join('\n')}`, + ); } } return parts.join('\n\n'); @@ -163,6 +172,10 @@ function turnToMarkdown(turn: ChatTurn): string { } else if (blk.kind === 'tool' && blk.tool.output && blk.tool.output.length > 0) { const output = blk.tool.output.join('\n'); parts.push(`\`\`\`\n[${blk.tool.name}]\n${output}\n\`\`\``); + } else if (blk.kind === 'agent') { + parts.push(`**Agent** ${blk.member.name} (${blk.member.phase})`); + } else if (blk.kind === 'agentGroup') { + parts.push(`**Agents**\n\n${blk.members.map((member) => `- ${member.name}: ${member.phase}`).join('\n')}`); } } return parts.join('\n\n'); @@ -254,7 +267,9 @@ type AssistantRenderBlock = | { kind: 'thinking'; thinking: string; sourceIndex: number } | { kind: 'text'; text: string; sourceIndex: number } | { kind: 'tool'; tool: ToolStackItem['tool']; sourceIndex: number } - | { kind: 'tool-stack'; tools: ToolStackItem[] }; + | { kind: 'tool-stack'; tools: ToolStackItem[] } + | { kind: 'agent'; member: Extract['member']; sourceIndex: number } + | { kind: 'agentGroup'; members: Extract['members']; sourceIndex: number }; function rendersToolCard(block: Extract): boolean { return !(block.tool.status === 'ok' && block.tool.media); @@ -296,8 +311,12 @@ function assistantRenderBlocks(turn: ChatTurn): AssistantRenderBlock[] { flushToolRun(); if (block.kind === 'thinking') { rendered.push({ kind: 'thinking', thinking: block.thinking, sourceIndex }); - } else { + } else if (block.kind === 'text') { rendered.push({ kind: 'text', text: block.text, sourceIndex }); + } else if (block.kind === 'agent') { + rendered.push({ kind: 'agent', member: block.member, sourceIndex }); + } else { + rendered.push({ kind: 'agentGroup', members: block.members, sourceIndex }); } }); @@ -319,6 +338,8 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string { return `tool-stack-${block.tools[0]?.sourceIndex ?? index}`; } if (block.kind === 'tool') return toolStackKey({ tool: block.tool, sourceIndex: block.sourceIndex }); + if (block.kind === 'agent') return `agent-${block.member.id}-${block.sourceIndex}`; + if (block.kind === 'agentGroup') return `agent-group-${block.members[0]?.id ?? block.sourceIndex}`; return `${block.kind}-${block.sourceIndex}`; } @@ -391,6 +412,8 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
+ +
@@ -501,6 +524,8 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
+ + @@ -592,6 +617,8 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string { .tx > :deep(.think), .tx > :deep(.md), .tx > .tool-stack, +.tx > :deep(.agent-card), +.tx > :deep(.agent-group), .tx > :deep(.box), .tx > :deep(.media-tool) { margin-top: var(--chat-block-gap); @@ -599,6 +626,8 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string { .tx > :deep(.think:first-child), .tx > :deep(.md:first-child), .tx > .tool-stack:first-child, +.tx > :deep(.agent-card:first-child), +.tx > :deep(.agent-group:first-child), .tx > :deep(.box:first-child), .tx > :deep(.media-tool:first-child) { margin-top: 0; diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index 102ebb17c..12e86c0b3 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -7,7 +7,7 @@ import MentionMenu from './MentionMenu.vue'; import type { SlashCommand } from '../lib/slashCommands'; import { buildSlashItems, filterCommands, parseSlash } from '../lib/slashCommands'; import type { FileItem } from './MentionMenu.vue'; -import type { ConversationStatus, PermissionMode, QueuedPromptView } from '../types'; +import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../types'; import type { AppModel, AppSkill, ThinkingLevel } from '../api/types'; // --------------------------------------------------------------------------- @@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{ status?: ConversationStatus; thinking?: ThinkingLevel; planMode?: boolean; + activationBadges?: ActivationBadges; /** Available models for the quick-switch dropdown. */ models?: AppModel[]; /** Session skills shown in the `/` menu (after the built-in commands). */ @@ -70,6 +71,8 @@ const emit = defineEmits<{ setPermission: [mode: PermissionMode]; setThinking: [level: ThinkingLevel]; togglePlan: []; + focusGoal: []; + focusSwarm: []; compact: []; pickModel: []; selectModel: [modelId: string]; @@ -625,6 +628,13 @@ function toggleThinking(): void { // Plan toggle const planOn = computed(() => props.planMode === true); +function formatElapsed(ms: number): string { + const minutes = Math.max(0, Math.round(ms / 60000)); + if (minutes < 1) return '<1m'; + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + // Permission modes const PERM_MODES: { mode: PermissionMode; color: string; labelKey: string; descKey: string }[] = [ { mode: 'manual', color: 'var(--dim)', labelKey: 'status.permissionManual', descKey: 'status.permissionManualDesc' }, @@ -874,6 +884,30 @@ function selectModel(modelId: string): void { @keydown.enter="emit('togglePlan')" @keydown.space.prevent="emit('togglePlan')" >{{ t('status.planLabel') }} + +
+ + + +
@@ -1576,6 +1610,52 @@ function selectModel(modelId: string): void { background: var(--soft); } +.activation-badges { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + margin-left: 4px; + overflow: hidden; +} +.abadge { + min-width: 0; + max-width: 220px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 7px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--bg); + color: var(--dim); + font-family: var(--mono); + font-size: 11px; + line-height: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} +.abadge:hover { + border-color: var(--bd); + background: var(--soft); +} +.abadge.plan, +.abadge.swarm { + color: var(--blue2); +} +.abadge.goal { + color: var(--ok); +} +.abadge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex: none; +} + /* ---- Mobile composer (prototype): round attach + rounded panel input + round blue send with a soft shadow. The .cin container loses its border and acts as a flex row; the textarea itself becomes the pill input. ---- */ @@ -1621,6 +1701,7 @@ function selectModel(modelId: string): void { at ≥80% usage) and tapping it triggers compaction directly. */ .perm-pill, .toggle-pill, + .activation-badges, .ctx-group { display: none; } diff --git a/apps/kimi-web/src/components/ConversationPane.vue b/apps/kimi-web/src/components/ConversationPane.vue index cf6e45fe7..10d80a4e2 100644 --- a/apps/kimi-web/src/components/ConversationPane.vue +++ b/apps/kimi-web/src/components/ConversationPane.vue @@ -2,8 +2,11 @@ diff --git a/apps/kimi-web/src/components/Terminal.vue b/apps/kimi-web/src/components/Terminal.vue new file mode 100644 index 000000000..b73a72e10 --- /dev/null +++ b/apps/kimi-web/src/components/Terminal.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/apps/kimi-web/src/components/ViewGroup.vue b/apps/kimi-web/src/components/ViewGroup.vue new file mode 100644 index 000000000..3ab828366 --- /dev/null +++ b/apps/kimi-web/src/components/ViewGroup.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/apps/kimi-web/src/composables/messagesToTurns.ts b/apps/kimi-web/src/composables/messagesToTurns.ts index 4ba65576f..d7fbc3bd4 100644 --- a/apps/kimi-web/src/composables/messagesToTurns.ts +++ b/apps/kimi-web/src/composables/messagesToTurns.ts @@ -9,9 +9,9 @@ // TOOL-role messages fold their toolResult content into the preceding assistant // group rather than becoming separate turns. -import type { AppMessage, AppApprovalRequest, CompactionMarkerMetadata } from '../api/types'; +import type { AppMessage, AppApprovalRequest, AppTask, CompactionMarkerMetadata } from '../api/types'; import { COMPACTION_MARKER_METADATA_KEY } from '../api/types'; -import type { ApprovalBlock, ChatTurn, DiffLine, ToolCall, ToolMedia, TurnBlock } from '../types'; +import type { AgentMember, ApprovalBlock, ChatTurn, DiffLine, ToolCall, ToolMedia, TurnBlock } from '../types'; const READ_MEDIA_TOOL_RE = /^read[_-]?media(?:file)?$/i; const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s; @@ -133,6 +133,29 @@ function normalizeToolOutput(output: unknown): string[] | undefined { return [JSON.stringify(output)]; } +function toAgentMember(task: AppTask): AgentMember { + return { + id: task.id, + toolCallId: task.parentToolCallId, + name: task.description, + subagentType: task.subagentType, + phase: + task.subagentPhase ?? + (task.status === 'completed' ? 'completed' : task.status === 'failed' ? 'failed' : 'working'), + status: task.status, + summary: task.outputPreview, + suspendedReason: task.suspendedReason, + swarmIndex: task.swarmIndex, + }; +} + +function sortAgentTasks(a: AppTask, b: AppTask): number { + const ai = a.swarmIndex ?? Number.MAX_SAFE_INTEGER; + const bi = b.swarmIndex ?? Number.MAX_SAFE_INTEGER; + if (ai !== bi) return ai - bi; + return a.createdAt.localeCompare(b.createdAt); +} + // --------------------------------------------------------------------------- // Inline buildApprovalBlock (mirrors the one in useKimiWebClient.ts; kept // here to avoid a circular import when tests import this module directly). @@ -317,6 +340,7 @@ export function messagesToTurns( * spinning forever after the turn already finished. */ sessionActive = true, + subagentTasks: AppTask[] = [], ): ChatTurn[] { const turns: ChatTurn[] = []; let no = 1; @@ -327,6 +351,20 @@ export function messagesToTurns( approvalByTool.set(a.toolCallId, a); } + const subagentsByTool = new Map(); + for (const task of subagentTasks) { + if (task.kind !== 'subagent') continue; + const keys = [task.parentToolCallId, task.id].filter((key): key is string => typeof key === 'string' && key.length > 0); + for (const key of keys) { + const list = subagentsByTool.get(key) ?? []; + list.push(task); + subagentsByTool.set(key, list); + } + } + for (const [key, list] of subagentsByTool.entries()) { + subagentsByTool.set(key, list.toSorted(sortAgentTasks)); + } + let pendingGroup: Group | null = null; function flushGroup(final = false): void { @@ -369,7 +407,7 @@ export function messagesToTurns( g.textParts.push(c.text); // Append to a trailing text block, else open a new one — so a tool // call between two text segments splits them into separate blocks. - const last = g.blocks[g.blocks.length - 1]; + const last = g.blocks.at(-1); if (last && last.kind === 'text') last.text += '\n' + c.text; else g.blocks.push({ kind: 'text', text: c.text }); } @@ -378,11 +416,22 @@ export function messagesToTurns( g.thinkingParts.push(c.thinking); // Ordered block too: thinking renders WHERE it happened in the turn, // merging consecutive segments (same rule as text blocks above). - const last = g.blocks[g.blocks.length - 1]; + const last = g.blocks.at(-1); if (last && last.kind === 'thinking') last.thinking += '\n' + c.thinking; else g.blocks.push({ kind: 'thinking', thinking: c.thinking }); } } else if (c.type === 'toolUse') { + const agentTasks = subagentsByTool.get(c.toolCallId); + if (agentTasks && agentTasks.length > 0) { + const members = agentTasks.map(toAgentMember); + if (members.length === 1) { + g.blocks.push({ kind: 'agent', member: members[0]! }); + } else { + g.blocks.push({ kind: 'agentGroup', members }); + } + continue; + } + const pendingApproval = approvalByTool.get(c.toolCallId); const toolCall: ToolCall = { id: c.toolCallId, diff --git a/apps/kimi-web/src/composables/swarmGroups.ts b/apps/kimi-web/src/composables/swarmGroups.ts new file mode 100644 index 000000000..4b99bd278 --- /dev/null +++ b/apps/kimi-web/src/composables/swarmGroups.ts @@ -0,0 +1,83 @@ +import type { AppSubagentPhase, AppTask } from '../api/types'; + +export interface SwarmMember { + id: string; + name: string; + subagentType?: string; + phase: AppSubagentPhase; + summary?: string; + suspendedReason?: string; + swarmIndex: number; +} + +export interface SwarmGroup { + id: string; + members: SwarmMember[]; + counts: Record; +} + +const PHASES: readonly AppSubagentPhase[] = ['queued', 'working', 'suspended', 'completed', 'failed']; + +function phaseForTask(task: AppTask): AppSubagentPhase { + if (task.subagentPhase) return task.subagentPhase; + if (task.status === 'completed') return 'completed'; + if (task.status === 'failed') return 'failed'; + return 'working'; +} + +function emptyCounts(): Record { + return { + queued: 0, + working: 0, + suspended: 0, + completed: 0, + failed: 0, + }; +} + +export function buildSwarmGroups(tasks: AppTask[]): SwarmGroup[] { + const buckets = new Map(); + + for (const task of tasks) { + if (task.kind !== 'subagent' || task.swarmIndex === undefined) continue; + const key = task.parentToolCallId ?? 'swarm'; + const list = buckets.get(key) ?? []; + list.push({ + id: task.id, + name: task.description, + subagentType: task.subagentType, + phase: phaseForTask(task), + summary: task.outputPreview, + suspendedReason: task.suspendedReason, + swarmIndex: task.swarmIndex, + }); + buckets.set(key, list); + } + + return [...buckets.entries()] + .map(([id, members]) => { + const sorted = members.toSorted((a, b) => a.swarmIndex - b.swarmIndex || a.id.localeCompare(b.id)); + const counts = emptyCounts(); + for (const member of sorted) counts[member.phase]++; + return { id, members: sorted, counts }; + }) + .filter((group) => group.members.length > 1) + .toSorted((a, b) => { + const ai = a.members.at(0)?.swarmIndex ?? 0; + const bi = b.members.at(0)?.swarmIndex ?? 0; + if (ai !== bi) return ai - bi; + return a.id.localeCompare(b.id); + }); +} + +export function countSwarmMembers(groups: SwarmGroup[]): { done: number; total: number } { + let done = 0; + let total = 0; + for (const group of groups) { + total += group.members.length; + for (const phase of PHASES) { + if (phase === 'completed' || phase === 'failed') done += group.counts[phase]; + } + } + return { done, total }; +} diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index cd2835f18..82d328d8e 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -8,6 +8,7 @@ import { getKimiWebApi } from '../api'; import { isDaemonApiError, isDaemonNetworkError } from '../api/errors'; import type { AppApprovalRequest, + AppGoal, AppNotice, AppNoticeDetail, AppMessage, @@ -17,6 +18,7 @@ import type { AppSession, AppSessionRuntimeStatus, AppSkill, + AppTask, AppWarning, AppWorkspace, ApprovalDecision, @@ -26,17 +28,18 @@ import type { QuestionResponse, ThinkingLevel, } from '../api/types'; -import { createInitialState, reduceAppEvent } from '../api/daemon/eventReducer'; -import type { CompactionStatus } from '../api/daemon/eventReducer'; +import { createInitialState, reduceAppEvent, type CompactionStatus, type KimiClientState } from '../api/daemon/eventReducer'; import { readSessionIdFromLocation, sessionUrl } from '../lib/sessionRoute'; import type { SessionUrlMode } from '../lib/sessionRoute'; -import type { KimiClientState } from '../api/daemon/eventReducer'; import { toAppEvent } from '../api/daemon/mappers'; import { parseDiff } from '../lib/parseDiff'; import { messagesToTurns } from './messagesToTurns'; import { latestTodos } from './latestTodos'; +import { buildSwarmGroups, countSwarmMembers } from './swarmGroups'; +import type { SwarmGroup } from './swarmGroups'; import type { ActivityState, + ActivationBadges, ApprovalBlock, ChatTurn, ConnectionState, @@ -476,6 +479,7 @@ function applyEvent(event: ReturnType, sessionId: string, seq approvalsBySession: rawState.approvalsBySession, questionsBySession: rawState.questionsBySession, tasksBySession: rawState.tasksBySession, + goalBySession: rawState.goalBySession, lastSeqBySession: rawState.lastSeqBySession, compactionBySession: rawState.compactionBySession, warnings: rawState.warnings, @@ -488,6 +492,7 @@ function applyEvent(event: ReturnType, sessionId: string, seq rawState.approvalsBySession = next.approvalsBySession; rawState.questionsBySession = next.questionsBySession; rawState.tasksBySession = next.tasksBySession; + rawState.goalBySession = next.goalBySession; rawState.lastSeqBySession = next.lastSeqBySession; rawState.compactionBySession = next.compactionBySession; rawState.warnings = next.warnings; @@ -717,6 +722,7 @@ async function handleSessionNotFound(sessionId: string): Promise { delete rawState.approvalsBySession[sessionId]; delete rawState.questionsBySession[sessionId]; delete rawState.tasksBySession[sessionId]; + delete rawState.goalBySession[sessionId]; delete rawState.gitStatusBySession[sessionId]; delete rawState.lastSeqBySession[sessionId]; delete rawState.compactionBySession[sessionId]; @@ -1077,6 +1083,12 @@ const isSending = computed(() => { return rawState.sendingBySession[sid] ?? false; }); +const activeAppTasks = computed(() => { + const sid = rawState.activeSessionId; + if (!sid) return []; + return rawState.tasksBySession[sid] ?? []; +}); + const turns = computed(() => { const sid = rawState.activeSessionId; if (!sid) return []; @@ -1087,13 +1099,20 @@ const turns = computed(() => { approvals, (fileId) => getKimiWebApi().getFileUrl(fileId), activity.value !== 'idle', + activeAppTasks.value, ); }); const tasks = computed(() => { + return activeAppTasks.value.map(toUiTask); +}); + +const swarms = computed(() => buildSwarmGroups(activeAppTasks.value)); + +const goal = computed(() => { const sid = rawState.activeSessionId; - if (!sid) return []; - return (rawState.tasksBySession[sid] ?? []).map(toUiTask); + if (!sid) return null; + return rawState.goalBySession[sid] ?? null; }); /** Current todo list of the active session (TodoList tool, latest write wins). */ @@ -1119,6 +1138,21 @@ const permission = computed(() => rawState.permission); const thinking = computed(() => rawState.thinking); const planMode = computed(() => rawState.planMode); +const activationBadges = computed(() => { + const swarmCounts = countSwarmMembers(swarms.value); + return { + plan: rawState.planMode, + goal: goal.value && goal.value.status !== 'complete' + ? { + status: goal.value.status, + turnsUsed: goal.value.turnsUsed, + elapsedMs: goal.value.wallClockMs, + } + : null, + swarm: swarmCounts.total > 0 ? swarmCounts : null, + }; +}); + /** Queued messages for the active session (text + attachment count for the composer strip — an image-only prompt would otherwise render as an empty string). */ @@ -2824,6 +2858,9 @@ export function useKimiWebClient() { turns, tasks, todos, + goal, + swarms, + activationBadges, compaction, status, sessionCost, diff --git a/apps/kimi-web/src/composables/usePaneLayout.ts b/apps/kimi-web/src/composables/usePaneLayout.ts new file mode 100644 index 000000000..97837b384 --- /dev/null +++ b/apps/kimi-web/src/composables/usePaneLayout.ts @@ -0,0 +1,155 @@ +import { ref } from 'vue'; +import type { PaneKey } from '../types'; + +export type PaneGroup = { + type: 'group'; + id: string; + views: PaneKey[]; + active: PaneKey; +}; + +export type PaneSplit = { + type: 'split'; + id: string; + dir: 'row' | 'col'; + children: PaneLayout[]; + sizes: number[]; +}; + +export type PaneLayout = PaneGroup | PaneSplit; + +const STORAGE_KEY = 'kimi-web.layout'; +const ALL_VIEWS: PaneKey[] = ['chat', 'files', 'tasks', 'todo', 'terminal']; + +function nextId(prefix: string): string { + return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; +} + +function defaultGroup(active: PaneKey = 'chat'): PaneGroup { + return { type: 'group', id: nextId('group'), views: [...ALL_VIEWS], active }; +} + +function isPaneKey(value: unknown): value is PaneKey { + return value === 'chat' || value === 'files' || value === 'tasks' || value === 'todo' || value === 'terminal'; +} + +function normalizeLayout(raw: unknown): PaneLayout | null { + if (!raw || typeof raw !== 'object') return null; + const node = raw as Record; + if (node['type'] === 'group') { + const active = isPaneKey(node['active']) ? node['active'] : 'chat'; + const views = Array.isArray(node['views']) + ? node['views'].filter(isPaneKey) + : ALL_VIEWS; + return { + type: 'group', + id: typeof node['id'] === 'string' ? node['id'] : nextId('group'), + views: views.length > 0 ? [...new Set(views)] : [...ALL_VIEWS], + active, + }; + } + if (node['type'] === 'split') { + const children = Array.isArray(node['children']) + ? node['children'].map(normalizeLayout).filter((item): item is PaneLayout => item !== null) + : []; + if (children.length === 0) return null; + const sizes = Array.isArray(node['sizes']) && node['sizes'].length === children.length + ? node['sizes'].map((size) => typeof size === 'number' && Number.isFinite(size) ? size : 1) + : children.map(() => 1); + return { + type: 'split', + id: typeof node['id'] === 'string' ? node['id'] : nextId('split'), + dir: node['dir'] === 'col' ? 'col' : 'row', + children, + sizes, + }; + } + return null; +} + +function loadLayout(): PaneLayout { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return normalizeLayout(JSON.parse(raw)) ?? defaultGroup(); + } catch { + // ignore + } + return defaultGroup(); +} + +function saveLayout(layout: PaneLayout): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); + } catch { + // ignore + } +} + +function updateGroup(layout: PaneLayout, groupId: string, fn: (group: PaneGroup) => PaneLayout): PaneLayout { + if (layout.type === 'group') return layout.id === groupId ? fn(layout) : layout; + return { + ...layout, + children: layout.children.map((child) => updateGroup(child, groupId, fn)), + }; +} + +function removeGroup(layout: PaneLayout, groupId: string): PaneLayout { + if (layout.type === 'group') return layout; + const children = layout.children + .filter((child) => child.type !== 'group' || child.id !== groupId) + .map((child) => removeGroup(child, groupId)); + if (children.length === 1) return children[0]!; + return { + ...layout, + children, + sizes: children.map((_, index) => layout.sizes[index] ?? 1), + }; +} + +function countGroups(layout: PaneLayout): number { + if (layout.type === 'group') return 1; + return layout.children.reduce((sum, child) => sum + countGroups(child), 0); +} + +export function usePaneLayout() { + const layout = ref(loadLayout()); + + function commit(next: PaneLayout): void { + layout.value = next; + saveLayout(next); + } + + function setActive(groupId: string, active: PaneKey): void { + commit(updateGroup(layout.value, groupId, (group) => ({ ...group, active }))); + } + + function split(groupId: string, dir: 'row' | 'col'): void { + commit(updateGroup(layout.value, groupId, (group) => ({ + type: 'split', + id: nextId('split'), + dir, + children: [group, defaultGroup(group.active === 'terminal' ? 'chat' : 'terminal')], + sizes: [1, 1], + }))); + } + + function close(groupId: string): void { + if (countGroups(layout.value) <= 1) return; + commit(removeGroup(layout.value, groupId)); + } + + function resize(splitId: string, sizes: number[]): void { + function visit(node: PaneLayout): PaneLayout { + if (node.type === 'group') return node; + if (node.id === splitId) return { ...node, sizes }; + return { ...node, children: node.children.map(visit) }; + } + commit(visit(layout.value)); + } + + function reset(): void { + commit(defaultGroup()); + } + + return { layout, setActive, split, close, resize, reset }; +} diff --git a/apps/kimi-web/src/composables/useTerminal.ts b/apps/kimi-web/src/composables/useTerminal.ts new file mode 100644 index 000000000..190c565e4 --- /dev/null +++ b/apps/kimi-web/src/composables/useTerminal.ts @@ -0,0 +1,142 @@ +import { onUnmounted, ref, watch, type Ref } from 'vue'; +import { getKimiWebApi } from '../api'; +import type { AppTerminal, KimiEventConnection } from '../api/types'; + +export function useTerminal(sessionId: Ref) { + const terminal = ref(null); + const loading = ref(false); + const error = ref(null); + const connected = ref(false); + const readOnly = ref(false); + const lastSeq = ref(0); + + const outputHandlers = new Set<(data: string) => void>(); + const exitHandlers = new Set<(exitCode: number | null) => void>(); + let conn: KimiEventConnection | null = null; + + function ensureConnection(): KimiEventConnection | null { + if (conn !== null) return conn; + if (typeof WebSocket === 'undefined') return null; + conn = getKimiWebApi().connectEvents({ + onEvent: () => {}, + onResync: () => {}, + onError: (_code, msg) => { + error.value = msg; + }, + onConnectionChange: (state) => { + connected.value = state; + }, + onTerminalOutput: (sid, terminalId, data, seq) => { + if (sid !== sessionId.value || terminal.value?.id !== terminalId) return; + lastSeq.value = Math.max(lastSeq.value, seq); + for (const handler of outputHandlers) handler(data); + }, + onTerminalExit: (sid, terminalId, exitCode) => { + if (sid !== sessionId.value || terminal.value?.id !== terminalId) return; + readOnly.value = true; + terminal.value = terminal.value + ? { ...terminal.value, status: 'exited', exitCode } + : terminal.value; + for (const handler of exitHandlers) handler(exitCode); + }, + }); + return conn; + } + + async function start(size?: { cols?: number; rows?: number }): Promise { + const sid = sessionId.value; + if (!sid || loading.value) return; + loading.value = true; + error.value = null; + try { + const api = getKimiWebApi(); + const existing = (await api.listTerminals(sid)).find((item) => item.status === 'running'); + const next = existing ?? await api.createTerminal(sid, { + cols: size?.cols, + rows: size?.rows, + }); + terminal.value = next; + readOnly.value = next.status === 'exited'; + ensureConnection()?.terminalAttach(sid, next.id, lastSeq.value); + } catch (error_) { + error.value = error_ instanceof Error ? error_.message : String(error_); + } finally { + loading.value = false; + } + } + + function write(data: string): void { + const current = terminal.value; + if (!current || readOnly.value) return; + ensureConnection()?.terminalInput(current.sessionId, current.id, data); + } + + function resize(cols: number, rows: number): void { + const current = terminal.value; + if (!current || readOnly.value) return; + ensureConnection()?.terminalResize(current.sessionId, current.id, cols, rows); + } + + async function close(): Promise { + const current = terminal.value; + if (!current) return; + readOnly.value = true; + try { + ensureConnection()?.terminalClose(current.sessionId, current.id); + await getKimiWebApi().closeTerminal(current.sessionId, current.id); + } catch (error_) { + error.value = error_ instanceof Error ? error_.message : String(error_); + } + } + + function restart(): void { + const current = terminal.value; + if (current) { + conn?.terminalDetach(current.sessionId, current.id); + } + terminal.value = null; + readOnly.value = false; + lastSeq.value = 0; + void start(); + } + + function onOutput(handler: (data: string) => void): () => void { + outputHandlers.add(handler); + return () => outputHandlers.delete(handler); + } + + function onExit(handler: (exitCode: number | null) => void): () => void { + exitHandlers.add(handler); + return () => exitHandlers.delete(handler); + } + + watch(sessionId, () => { + const current = terminal.value; + if (current) conn?.terminalDetach(current.sessionId, current.id); + terminal.value = null; + readOnly.value = false; + lastSeq.value = 0; + }); + + onUnmounted(() => { + const current = terminal.value; + if (current) conn?.terminalDetach(current.sessionId, current.id); + conn?.close(); + conn = null; + }); + + return { + terminal, + loading, + error, + connected, + readOnly, + start, + write, + resize, + close, + restart, + onOutput, + onExit, + }; +} diff --git a/apps/kimi-web/src/i18n/locales/en/sidebar.ts b/apps/kimi-web/src/i18n/locales/en/sidebar.ts index 88c4328c7..952faf139 100644 --- a/apps/kimi-web/src/i18n/locales/en/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/en/sidebar.ts @@ -25,6 +25,7 @@ export default { tabFiles: 'files', tabTasks: 'tasks', tabTodo: 'todo', + tabTerminal: 'terminal', noSessions: 'No conversations yet', showMore: 'Show more ({count})', } as const; diff --git a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts index 99c91a619..aa6f4b845 100644 --- a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts @@ -25,6 +25,7 @@ export default { tabFiles: '文件', tabTasks: '后台任务', tabTodo: '待办', + tabTerminal: '终端', noSessions: '暂无对话', showMore: '展开更多 ({count})', }; diff --git a/apps/kimi-web/src/types.ts b/apps/kimi-web/src/types.ts index 22ed603af..a7ddfae93 100644 --- a/apps/kimi-web/src/types.ts +++ b/apps/kimi-web/src/types.ts @@ -67,6 +67,20 @@ export interface ToolMedia { dimensions?: string; } +export type AgentPhase = 'queued' | 'working' | 'suspended' | 'completed' | 'failed'; + +export interface AgentMember { + id: string; + toolCallId?: string; + name: string; + subagentType?: string; + phase: AgentPhase; + status: 'running' | 'completed' | 'failed' | 'cancelled'; + summary?: string; + suspendedReason?: string; + swarmIndex?: number; +} + export type DiffKind = 'ctx' | 'add' | 'rem'; export interface DiffLine { @@ -123,7 +137,9 @@ export interface FilePreviewRequest { export type TurnBlock = | { kind: 'text'; text: string } | { kind: 'thinking'; thinking: string } - | { kind: 'tool'; tool: ToolCall }; + | { kind: 'tool'; tool: ToolCall } + | { kind: 'agent'; member: AgentMember } + | { kind: 'agentGroup'; members: AgentMember[] }; export interface ChatTurn { id: string; @@ -188,7 +204,13 @@ export interface ConversationStatus { // ~/diff and ~/files were merged into a single ~/files tab (changed-first list + // a Changed|All toggle + an adaptive content pane: diff for changed files, content // preview for unchanged ones). 'diff' is gone; 'files' is the merged key. -export type PaneKey = 'chat' | 'files' | 'tasks' | 'todo'; +export type PaneKey = 'chat' | 'files' | 'tasks' | 'todo' | 'terminal'; + +export interface ActivationBadges { + plan: boolean; + goal: { status: string; turnsUsed: number; elapsedMs: number } | null; + swarm: { done: number; total: number } | null; +} /** A queued prompt as shown in the composer's queue strip. */ export interface QueuedPromptView { diff --git a/apps/kimi-web/test/agent-group-turns.test.ts b/apps/kimi-web/test/agent-group-turns.test.ts new file mode 100644 index 000000000..111d6441e --- /dev/null +++ b/apps/kimi-web/test/agent-group-turns.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { messagesToTurns } from '../src/composables/messagesToTurns'; +import type { AppMessage, AppTask } from '../src/api/types'; + +const now = '2026-06-13T00:00:00.000Z'; + +describe('messagesToTurns agent blocks', () => { + it('renders one subagent task as an agent block', () => { + const messages: AppMessage[] = [ + { + id: 'msg_1', + sessionId: 'ses_1', + role: 'assistant', + promptId: 'pr_1', + createdAt: now, + content: [ + { type: 'text', text: 'starting review' }, + { type: 'toolUse', toolCallId: 'tc_agent', toolName: 'agent', input: { description: 'review' } }, + ], + }, + ]; + const tasks: AppTask[] = [ + { + id: 'agent_1', + sessionId: 'ses_1', + kind: 'subagent', + description: 'Review code', + status: 'running', + createdAt: now, + subagentPhase: 'working', + subagentType: 'coder', + parentToolCallId: 'tc_agent', + }, + ]; + + const turns = messagesToTurns(messages, [], undefined, true, tasks); + expect(turns[0]?.blocks?.[1]).toEqual({ + kind: 'agent', + member: expect.objectContaining({ + id: 'agent_1', + name: 'Review code', + phase: 'working', + subagentType: 'coder', + }), + }); + expect(turns[0]?.tools).toBeUndefined(); + }); + + it('renders multiple subagent tasks with the same parent tool as an agentGroup block', () => { + const messages: AppMessage[] = [ + { + id: 'msg_1', + sessionId: 'ses_1', + role: 'assistant', + promptId: 'pr_1', + createdAt: now, + content: [ + { type: 'toolUse', toolCallId: 'tc_swarm', toolName: 'agent_swarm', input: { description: 'review', count: 2 } }, + ], + }, + ]; + const tasks: AppTask[] = [ + { + id: 'agent_b', + sessionId: 'ses_1', + kind: 'subagent', + description: 'Second', + status: 'running', + createdAt: now, + subagentPhase: 'queued', + parentToolCallId: 'tc_swarm', + swarmIndex: 2, + }, + { + id: 'agent_a', + sessionId: 'ses_1', + kind: 'subagent', + description: 'First', + status: 'completed', + createdAt: now, + subagentPhase: 'completed', + parentToolCallId: 'tc_swarm', + swarmIndex: 1, + }, + ]; + + const turns = messagesToTurns(messages, [], undefined, false, tasks); + const block = turns[0]?.blocks?.[0]; + expect(block?.kind).toBe('agentGroup'); + if (block?.kind !== 'agentGroup') return; + expect(block.members.map((member) => member.id)).toEqual(['agent_a', 'agent_b']); + expect(block.members.map((member) => member.phase)).toEqual(['completed', 'queued']); + }); +}); diff --git a/apps/kimi-web/test/subagent-goal.test.ts b/apps/kimi-web/test/subagent-goal.test.ts new file mode 100644 index 000000000..6419e7c14 --- /dev/null +++ b/apps/kimi-web/test/subagent-goal.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { createAgentProjector } from '../src/api/daemon/agentEventProjector'; +import { createInitialState, reduceAppEvent } from '../src/api/daemon/eventReducer'; +import { toAppEvent } from '../src/api/daemon/mappers'; + +describe('subagent and goal projection', () => { + it('tracks subagent lifecycle metadata across partial events', () => { + const projector = createAgentProjector(); + const sid = 'ses_1'; + + const spawned = projector.project('subagent.spawned', { + subagentId: 'agent_1', + subagentName: 'coder', + parentToolCallId: 'tc_agent', + description: 'Review API timeout', + swarmIndex: 2, + }, sid); + expect(spawned).toEqual([ + expect.objectContaining({ + type: 'taskCreated', + task: expect.objectContaining({ + id: 'agent_1', + description: 'Review API timeout', + subagentPhase: 'queued', + subagentType: 'coder', + parentToolCallId: 'tc_agent', + swarmIndex: 2, + }), + }), + ]); + + const started = projector.project('subagent.started', { subagentId: 'agent_1' }, sid); + expect(started[0]).toEqual(expect.objectContaining({ + type: 'taskCreated', + task: expect.objectContaining({ + id: 'agent_1', + description: 'Review API timeout', + subagentPhase: 'working', + parentToolCallId: 'tc_agent', + }), + })); + + const suspended = projector.project('subagent.suspended', { subagentId: 'agent_1', reason: 'rate limit' }, sid); + expect(suspended[0]).toEqual(expect.objectContaining({ + type: 'taskCreated', + task: expect.objectContaining({ + subagentPhase: 'suspended', + suspendedReason: 'rate limit', + }), + })); + + const completed = projector.project('subagent.completed', { subagentId: 'agent_1', resultSummary: 'ok' }, sid); + expect(completed).toEqual([ + expect.objectContaining({ + type: 'taskCreated', + task: expect.objectContaining({ + subagentPhase: 'completed', + status: 'completed', + outputPreview: 'ok', + }), + }), + expect.objectContaining({ + type: 'taskCompleted', + taskId: 'agent_1', + status: 'completed', + outputPreview: 'ok', + }), + ]); + }); + + it('stores active goals and clears complete/null goals in the reducer', () => { + const projector = createAgentProjector(); + const sid = 'ses_1'; + const [active] = projector.project('goal.updated', { + snapshot: { + goalId: 'goal_1', + objective: 'Ship P3', + completionCriterion: 'All checks pass', + status: 'active', + turnsUsed: 3, + tokensUsed: 1200, + wallClockMs: 90_000, + budget: { + tokenBudget: 10_000, + remainingTokens: 8_800, + turnBudget: 10, + remainingTurns: 7, + wallClockBudgetMs: null, + remainingWallClockMs: null, + overBudget: false, + }, + }, + }, sid); + + let state = reduceAppEvent(createInitialState(), active!, { sessionId: sid, seq: 1 }); + expect(state.goalBySession[sid]).toEqual(expect.objectContaining({ + goalId: 'goal_1', + objective: 'Ship P3', + status: 'active', + turnsUsed: 3, + })); + + const [complete] = projector.project('goal.updated', { + snapshot: { + goalId: 'goal_1', + objective: 'Ship P3', + status: 'complete', + turnsUsed: 4, + tokensUsed: 1600, + wallClockMs: 100_000, + budget: { tokenBudget: null, remainingTokens: null, turnBudget: null, remainingTurns: null, wallClockBudgetMs: null, remainingWallClockMs: null, overBudget: false }, + }, + }, sid); + state = reduceAppEvent(state, complete!, { sessionId: sid, seq: 2 }); + expect(state.goalBySession[sid]).toBeUndefined(); + + const [cleared] = projector.project('goal.updated', { snapshot: null }, sid); + state = reduceAppEvent(state, cleared!, { sessionId: sid, seq: 3 }); + expect(state.goalBySession[sid]).toBeUndefined(); + }); + + it('maps projected goal events from the daemon wire protocol', () => { + const event = toAppEvent({ + type: 'event.goal.updated', + seq: 1, + session_id: 'ses_1', + timestamp: '2026-06-13T00:00:00.000Z', + payload: { + snapshot: { + goal_id: 'goal_1', + objective: 'Ship P3', + completion_criterion: 'All checks pass', + status: 'active', + turns_used: 3, + tokens_used: 1200, + wall_clock_ms: 90_000, + budget: { + token_budget: 10_000, + remaining_tokens: 8_800, + turn_budget: 10, + remaining_turns: 7, + over_budget: false, + }, + }, + }, + } as never); + + expect(event).toEqual(expect.objectContaining({ + type: 'goalUpdated', + sessionId: 'ses_1', + goal: expect.objectContaining({ + goalId: 'goal_1', + objective: 'Ship P3', + completionCriterion: 'All checks pass', + status: 'active', + turnsUsed: 3, + }), + })); + }); +}); diff --git a/apps/kimi-web/test/swarm-groups.test.ts b/apps/kimi-web/test/swarm-groups.test.ts new file mode 100644 index 000000000..c935f790e --- /dev/null +++ b/apps/kimi-web/test/swarm-groups.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import type { AppTask } from '../src/api/types'; +import { buildSwarmGroups, countSwarmMembers } from '../src/composables/swarmGroups'; + +const now = '2026-06-13T00:00:00.000Z'; + +function task(input: Partial & Pick): AppTask { + return { + sessionId: 'ses_1', + kind: 'subagent', + description: input.id, + status: 'running', + createdAt: now, + ...input, + }; +} + +describe('buildSwarmGroups', () => { + it('groups subagents by parent tool call and sorts by swarmIndex', () => { + const groups = buildSwarmGroups([ + task({ id: 'agent_2', parentToolCallId: 'tc_1', swarmIndex: 2, subagentPhase: 'working' }), + task({ id: 'agent_1', parentToolCallId: 'tc_1', swarmIndex: 1, subagentPhase: 'completed', status: 'completed' }), + task({ id: 'agent_3', parentToolCallId: 'tc_2', swarmIndex: 1, subagentPhase: 'queued' }), + task({ id: 'bash_1', kind: 'bash', swarmIndex: 3 }), + ]); + + expect(groups).toHaveLength(1); + expect(groups[0]?.id).toBe('tc_1'); + expect(groups[0]?.members.map((member) => member.id)).toEqual(['agent_1', 'agent_2']); + expect(groups[0]?.counts).toEqual({ + queued: 0, + working: 1, + suspended: 0, + completed: 1, + failed: 0, + }); + }); + + it('counts terminal swarm members for badges', () => { + const groups = buildSwarmGroups([ + task({ id: 'agent_1', parentToolCallId: 'tc_1', swarmIndex: 1, subagentPhase: 'completed', status: 'completed' }), + task({ id: 'agent_2', parentToolCallId: 'tc_1', swarmIndex: 2, subagentPhase: 'failed', status: 'failed' }), + task({ id: 'agent_3', parentToolCallId: 'tc_1', swarmIndex: 3, subagentPhase: 'working' }), + ]); + + expect(countSwarmMembers(groups)).toEqual({ done: 2, total: 3 }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47a8b18d2..c83c249f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,12 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + zod: + specifier: 4.3.6 + version: 4.3.6 + overrides: ssh2@1.17.0>cpu-features: '-' ssh2@1.17.0>nan: '-' @@ -139,6 +145,12 @@ importers: '@fontsource-variable/jetbrains-mono': specifier: ^5.2.8 version: 5.2.8 + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 markstream-vue: specifier: 1.0.1-beta.5 version: 1.0.1-beta.5(katex@0.16.47)(mermaid@11.15.0)(stream-markdown@0.0.15(react@19.2.5)(shiki@4.2.0)(vue@3.5.35(typescript@6.0.2)))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)) @@ -2955,6 +2967,12 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + a-sync-waterfall@1.0.1: resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==} @@ -8597,6 +8615,10 @@ snapshots: transitivePeerDependencies: - typescript + '@xterm/addon-fit@0.11.0': {} + + '@xterm/xterm@6.0.0': {} + a-sync-waterfall@1.0.1: {} abbrev@2.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 610c96158..6763a37c9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,9 @@ packages: - apps/vis/web - docs +catalog: + zod: 4.3.6 + overrides: "ssh2@1.17.0>cpu-features": "-" "ssh2@1.17.0>nan": "-"