feat(web): land P3 — goal / swarm / subagent + terminal + view split

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.
This commit is contained in:
qer 2026-06-13 22:53:20 +08:00
parent be7ab774ee
commit f5a7f21c68
34 changed files with 2930 additions and 44 deletions

View file

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

View file

@ -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",

View file

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

View file

@ -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<string, AppTask>;
}
function createSessionState(): SessionState {
@ -102,9 +114,76 @@ function createSessionState(): SessionState {
turnCount: 0,
model: '',
messages: [],
subagentMeta: new Map(),
};
}
function stringField(source: Record<string, unknown>, key: string): string | undefined {
const value = source[key];
return typeof value === 'string' ? value : undefined;
}
function numberField(source: Record<string, unknown>, key: string): number | undefined {
const value = source[key];
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function nullableNumberField(source: Record<string, unknown>, 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<string, unknown>;
const budgetRaw = s['budget'];
const budget = budgetRaw && typeof budgetRaw === 'object' ? budgetRaw as Record<string, unknown> : {};
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>,
): 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',

View file

@ -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<AppTerminal[]> {
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<AppTerminal> {
const body: Record<string, unknown> = {
cwd: input.cwd,
shell: input.shell,
cols: input.cols,
rows: input.rows,
};
const data = await this.http.post<WireTerminal>(
`/sessions/${encodeURIComponent(sessionId)}/terminals`,
body,
);
return toAppTerminal(data);
}
async getTerminal(sessionId: string, terminalId: string): Promise<AppTerminal> {
const data = await this.http.get<WireTerminal>(
`/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();
},

View file

@ -11,6 +11,7 @@
import type {
AppApprovalRequest,
AppEvent,
AppGoal,
AppMessage,
AppMessageContent,
AppWarning,
@ -43,6 +44,7 @@ export interface KimiClientState {
approvalsBySession: Record<string, AppApprovalRequest[]>;
questionsBySession: Record<string, AppQuestionRequest[]>;
tasksBySession: Record<string, AppTask[]>;
goalBySession: Record<string, AppGoal>;
lastSeqBySession: Record<string, number>;
compactionBySession: Record<string, CompactionStatus>;
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

View file

@ -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<string, unknown>, key: string): string | undefined {
const value = source[key];
return typeof value === 'string' ? value : undefined;
}
function recordNumber(source: Record<string, unknown>, key: string): number | undefined {
const value = source[key];
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function recordNullableNumber(source: Record<string, unknown>, 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<string, unknown>;
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<string, unknown> : {};
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) };

View file

@ -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;
}
// ---------------------------------------------------------------------------

View file

@ -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<string, TerminalAttachment>();
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<string, SessionCursor>): 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}`;
}

View file

@ -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<AppTask[]>;
getTask(sessionId: string, taskId: string, input?: { withOutput?: boolean; outputBytes?: number }): Promise<AppTask>;
cancelTask(sessionId: string, taskId: string): Promise<{ cancelled: true }>;
listTerminals(sessionId: string): Promise<AppTerminal[]>;
createTerminal(sessionId: string, input?: { cwd?: string; shell?: string; cols?: number; rows?: number }): Promise<AppTerminal>;
getTerminal(sessionId: string, terminalId: string): Promise<AppTerminal>;
closeTerminal(sessionId: string, terminalId: string): Promise<{ closed: true }>;
listDirectory(sessionId: string, input: { path?: string; depth?: number; includeGitStatus?: boolean }): Promise<{ items: FsEntry[]; childrenByPath?: Record<string, FsEntry[]>; 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 }>;

View file

@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { AgentMember } from '../types';
const props = defineProps<{ member: AgentMember; compact?: boolean }>();
const expanded = ref(false);
const hasDetail = computed(() => Boolean(props.member.summary || props.member.suspendedReason));
function phaseLabel(phase: AgentMember['phase']): string {
switch (phase) {
case 'queued': return 'Queued';
case 'working': return 'Working';
case 'suspended': return 'Suspended';
case 'completed': return 'Completed';
case 'failed': return 'Failed';
}
}
function toggle(): void {
if (hasDetail.value) expanded.value = !expanded.value;
}
</script>
<template>
<div class="agent-card" :class="[`phase-${member.phase}`, { compact }]">
<button class="agent-head" type="button" :disabled="!hasDetail" @click="toggle">
<span class="agent-dot" aria-hidden="true"></span>
<span class="agent-main">
<span class="agent-name">{{ member.name }}</span>
<span v-if="member.subagentType" class="agent-type">{{ member.subagentType }}</span>
</span>
<span class="agent-phase">{{ phaseLabel(member.phase) }}</span>
<svg
v-if="hasDetail"
class="agent-chevron"
:class="{ open: expanded }"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M6 4l4 4-4 4" />
</svg>
</button>
<div v-if="hasDetail && expanded" class="agent-detail">
<div v-if="member.suspendedReason" class="agent-reason">{{ member.suspendedReason }}</div>
<div v-if="member.summary" class="agent-summary">{{ member.summary }}</div>
</div>
</div>
</template>
<style scoped>
.agent-card {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
overflow: hidden;
}
.agent-card.compact {
border-radius: 6px;
}
.agent-head {
width: 100%;
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: none;
background: transparent;
color: var(--ink);
font: inherit;
text-align: left;
}
.agent-head:not(:disabled) {
cursor: pointer;
}
.agent-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--blue);
flex: none;
}
.phase-completed .agent-dot { background: var(--ok); }
.phase-failed .agent-dot { background: var(--err); }
.phase-suspended .agent-dot { background: var(--warn); }
.phase-queued .agent-dot { background: var(--muted); }
.agent-main {
min-width: 0;
flex: 1;
display: flex;
align-items: baseline;
gap: 7px;
}
.agent-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
}
.agent-type {
flex: none;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
}
.agent-phase {
flex: none;
border: 1px solid var(--line);
border-radius: 999px;
padding: 1px 7px;
color: var(--dim);
background: var(--bg);
font-family: var(--mono);
font-size: 10.5px;
}
.agent-chevron {
flex: none;
width: 14px;
height: 14px;
color: var(--muted);
transition: transform 0.12s;
}
.agent-chevron.open {
transform: rotate(90deg);
}
.agent-detail {
border-top: 1px solid var(--line);
padding: 8px 10px 10px 26px;
color: var(--dim);
font-size: 12px;
line-height: 1.5;
}
.agent-reason {
color: var(--warn);
margin-bottom: 4px;
}
.agent-summary {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>

View file

@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { AgentMember } from '../types';
import AgentCard from './AgentCard.vue';
const props = defineProps<{ members: AgentMember[] }>();
const expanded = ref(true);
const done = computed(() =>
props.members.filter((m) => m.phase === 'completed' || m.phase === 'failed').length,
);
const running = computed(() =>
props.members.filter((m) => m.phase === 'queued' || m.phase === 'working' || m.phase === 'suspended').length,
);
</script>
<template>
<section class="agent-group">
<button class="group-head" type="button" @click="expanded = !expanded">
<span class="group-title">Agents</span>
<span class="group-count">{{ done }}/{{ members.length }}</span>
<span v-if="running > 0" class="group-live">{{ running }} running</span>
<svg
class="group-chevron"
:class="{ open: expanded }"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M6 4l4 4-4 4" />
</svg>
</button>
<div v-if="expanded" class="group-body">
<AgentCard v-for="member in members" :key="member.id" :member="member" compact />
</div>
</section>
</template>
<style scoped>
.agent-group {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
overflow: hidden;
}
.group-head {
width: 100%;
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: none;
background: var(--panel2);
color: var(--ink);
font: inherit;
cursor: pointer;
}
.group-title {
font-weight: 700;
font-size: 13px;
}
.group-count {
border-radius: 999px;
padding: 1px 7px;
background: var(--soft);
color: var(--blue2);
font-family: var(--mono);
font-size: 11px;
}
.group-live {
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
}
.group-chevron {
margin-left: auto;
flex: none;
width: 14px;
height: 14px;
color: var(--muted);
transition: transform 0.12s;
}
.group-chevron.open {
transform: rotate(90deg);
}
.group-body {
display: grid;
gap: 8px;
padding: 10px;
}
</style>

View file

@ -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<string | null>(() => {
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<TurnBlock, { kind: 'agent' }>['member']; sourceIndex: number }
| { kind: 'agentGroup'; members: Extract<TurnBlock, { kind: 'agentGroup' }>['members']; sourceIndex: number };
function rendersToolCard(block: Extract<TurnBlock, { kind: 'tool' }>): 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 {
<div v-else-if="blk.kind === 'tool-stack'" class="tool-stack">
<ToolCall v-for="(item, si) in blk.tools" :key="toolStackKey(item)" :tool="item.tool" :mobile="childBubble" :stack-position="toolStackPosition(si, blk.tools.length)" @open-media="emit('openMedia', $event)" />
</div>
<AgentCard v-else-if="blk.kind === 'agent'" :member="blk.member" />
<AgentGroup v-else-if="blk.kind === 'agentGroup'" :members="blk.members" />
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" :mobile="childBubble" @open-media="emit('openMedia', $event)" />
</template>
<div v-if="turn.id !== streamingTurnId && isAssistantRunEnd(ti)" class="a-msg-ft">
@ -501,6 +524,8 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string {
<div v-else-if="blk.kind === 'tool-stack'" class="tool-stack">
<ToolCall v-for="(item, si) in blk.tools" :key="toolStackKey(item)" :tool="item.tool" :stack-position="toolStackPosition(si, blk.tools.length)" @open-media="emit('openMedia', $event)" />
</div>
<AgentCard v-else-if="blk.kind === 'agent'" :member="blk.member" />
<AgentGroup v-else-if="blk.kind === 'agentGroup'" :members="blk.members" />
<ToolCall v-else-if="blk.kind === 'tool'" :tool="blk.tool" @open-media="emit('openMedia', $event)" />
</template>
</template>
@ -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;

View file

@ -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') }}</span>
<div v-if="activationBadges && (activationBadges.plan || activationBadges.goal || activationBadges.swarm)" class="activation-badges">
<button
v-if="activationBadges.plan"
class="abadge plan"
type="button"
@click="emit('togglePlan')"
>plan</button>
<button
v-if="activationBadges.goal"
class="abadge goal"
type="button"
@click="emit('focusGoal')"
>
<span class="abadge-dot" aria-hidden="true"></span>
goal {{ activationBadges.goal.status }} · {{ formatElapsed(activationBadges.goal.elapsedMs) }} · {{ activationBadges.goal.turnsUsed }} turns
</button>
<button
v-if="activationBadges.swarm"
class="abadge swarm"
type="button"
@click="emit('focusSwarm')"
>swarm {{ activationBadges.swarm.done }}/{{ activationBadges.swarm.total }}</button>
</div>
</div>
<!-- Right: ctx + model -->
@ -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;
}

View file

@ -2,8 +2,11 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ActivityState, ApprovalBlock, ChatTurn, ConnectionState, ConversationStatus, DiffViewLine, FilePreviewRequest, PaneKey, PermissionMode, QueuedPromptView, TaskItem, TodoView, ToolMedia, UIQuestion } from '../types';
import type { AppModel, AppSkill, FsEntry, QuestionResponse, ThinkingLevel } from '../api/types';
import type { ActivityState, ActivationBadges, ApprovalBlock, ChatTurn, ConnectionState, ConversationStatus, DiffViewLine, FilePreviewRequest, PaneKey, PermissionMode, QueuedPromptView, TaskItem, TodoView, ToolMedia, UIQuestion } from '../types';
import type { AppGoal, AppModel, AppSkill, FsEntry, QuestionResponse, ThinkingLevel } from '../api/types';
import type { SwarmGroup } from '../composables/swarmGroups';
import { usePaneLayout } from '../composables/usePaneLayout';
import type { PaneGroup, PaneLayout } from '../composables/usePaneLayout';
import type { FileItem } from './MentionMenu.vue';
import type { FileData } from './FilePreview.vue';
import TabBar from './TabBar.vue';
@ -16,11 +19,17 @@ import TodoCard from './TodoCard.vue';
import FileTree from './FileTree.vue';
import FilePreview from './FilePreview.vue';
import Composer from './Composer.vue';
import Terminal from './Terminal.vue';
import QuestionCard from './QuestionCard.vue';
import ApprovalCard from './ApprovalCard.vue';
import SwarmCard from './SwarmCard.vue';
import GoalStrip from './GoalStrip.vue';
import ViewGroup from './ViewGroup.vue';
import SplitLayout from './SplitLayout.vue';
const props = defineProps<{
turns: ChatTurn[];
sessionId?: string;
approvals?: { approvalId: string; block: ApprovalBlock; agentName?: string }[];
changes?: { path: string; status: string }[];
gitInfo?: { branch: string; ahead: number; behind: number } | null;
@ -33,6 +42,9 @@ const props = defineProps<{
tasks: TaskItem[];
/** Model-maintained todo list (TodoList tool) — shown as a floating card. */
todos?: TodoView[];
goal?: AppGoal | null;
swarms?: SwarmGroup[];
activationBadges?: ActivationBadges;
status: ConversationStatus;
thinking?: ThinkingLevel;
planMode?: boolean;
@ -101,16 +113,31 @@ try {
// expose a way for App.vue to imperatively switch to tasks tab
const active = ref<PaneKey>('chat');
const paneLayout = usePaneLayout();
const chatPaneRef = ref<InstanceType<typeof ChatPane> | null>(null);
const copyConversationCopied = ref(false);
const goalExpandSignal = ref(0);
let copyConversationCopiedTimer: ReturnType<typeof setTimeout> | null = null;
/** Called by App.vue via command routing to switch to a specific tab */
function switchTab(tab: PaneKey): void {
active.value = tab;
const root = paneLayout.layout.value;
const groupId = root.type === 'group' ? root.id : firstGroupId(root);
if (groupId) paneLayout.setActive(groupId, tab);
}
defineExpose({ switchTab });
function firstGroupId(node: PaneLayout): string | undefined {
if (node.type === 'group') return node.id;
return node.children.map(firstGroupId).find((id): id is string => id !== undefined);
}
function selectGroupPane(group: PaneGroup, pane: PaneKey): void {
active.value = pane;
paneLayout.setActive(group.id, pane);
}
function handleCopyConversationCopied(): void {
copyConversationCopied.value = true;
if (copyConversationCopiedTimer !== null) clearTimeout(copyConversationCopiedTimer);
@ -120,6 +147,18 @@ function handleCopyConversationCopied(): void {
}, 2000);
}
function focusGoal(): void {
goalExpandSignal.value++;
}
function focusSwarm(): void {
active.value = 'chat';
void nextTick(() => {
const first = panesRef.value?.querySelector<HTMLElement>('.swarm-card');
first?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
// The TabBar is hidden for an empty session (the centred quick-start composer
// takes the whole pane) if the user was parked on tasks/todo/files when the
// session emptied out, they'd be trapped with no tabs AND no composer. Snap
@ -367,7 +406,7 @@ const scrollKey = computed(() => {
const approvalIds = (props.approvals ?? []).map((a) => a.approvalId).join(',');
const t = props.turns;
if (t.length === 0) return `0|${approvalIds}`;
const last = t[t.length - 1]!;
const last = t.at(-1)!;
const thinkingLen = last.thinking?.length ?? 0;
const toolsLen =
last.tools?.reduce(
@ -581,7 +620,7 @@ onUnmounted(() => {
<template>
<section class="con" :class="{ mobile }">
<TabBar
v-if="!(turns.length === 0 && !sessionLoading)"
v-if="mobile && !(turns.length === 0 && !sessionLoading)"
:active="active"
:running-tasks="runningTasks"
:changes-count="changesCount"
@ -604,10 +643,114 @@ onUnmounted(() => {
<TasksCard v-if="runningTasks > 0" :tasks="tasks" @open="active = 'tasks'" />
</div>
<SplitLayout
v-if="!mobile && !(turns.length === 0 && !sessionLoading)"
class="layout-root"
:layout="paneLayout.layout.value"
@resize="paneLayout.resize"
>
<template #group="{ group }">
<ViewGroup
:active="group.active"
:running-tasks="runningTasks"
:changes-count="changesCount"
:todos="todos ?? []"
:can-close="paneLayout.layout.value.type !== 'group'"
:show-copy-conversation="group.active === 'chat' && turns.length > 0"
:copy-conversation-copied="copyConversationCopied"
@select="selectGroupPane(group, $event)"
@split="paneLayout.split(group.id, $event)"
@close="paneLayout.close(group.id)"
@copy-conversation="chatPaneRef?.copyConversation()"
>
<div
:ref="group.active === 'chat' ? 'panesRef' : undefined"
class="panes group-panes"
:class="{ 'files-layout': group.active === 'files', 'terminal-layout': group.active === 'terminal' }"
@scroll.passive="group.active === 'chat' ? onPanesScroll() : undefined"
>
<div v-if="group.active === 'chat'" class="content-wrap" :class="[mobile ? 'align-mobile' : 'align-center']">
<ChatPane
ref="chatPaneRef"
:key="fileReloadKey ?? 'no-session'"
:turns="turns"
:approvals="approvals"
:bubble="bubble"
:mobile="mobile"
:running="running"
:sending="sending"
:session-loading="sessionLoading"
:compaction="compaction"
@open-file="emit('openFile', $event)"
@open-media="emit('openMedia', $event)"
@copy-conversation-copied="handleCopyConversationCopied"
@open-thinking="emit('openThinking', $event)"
@open-compaction="emit('openCompaction', $event)"
/>
<div v-if="(swarms?.length ?? 0) > 0" class="swarm-stack">
<SwarmCard v-for="groupItem in swarms" :key="groupItem.id" :group="groupItem" />
</div>
</div>
<TasksPane
v-else-if="group.active === 'tasks'"
:tasks="tasks"
@cancel="emit('cancelTask', $event)"
/>
<TodoCard
v-else-if="group.active === 'todo'"
:todos="todos ?? []"
inline
/>
<Terminal
v-else-if="group.active === 'terminal' && sessionId"
:session-id="sessionId"
/>
<template v-else-if="group.active === 'files'">
<div v-show="!mobile || !filesShowPreview" class="files-nav">
<div v-if="hasGit" class="nav-seg">
<div class="seg-group" role="group" :aria-label="t('fileTree.segLabel')">
<button type="button" class="seg-btn" :class="{ on: changedView === 'changed' }" :aria-pressed="changedView === 'changed'" @click="changedView = 'changed'">
{{ t('fileTree.changed') }}
<span v-if="(changesCount ?? 0) > 0" class="seg-n">{{ changesCount }}</span>
</button>
<button type="button" class="seg-btn" :class="{ on: changedView === 'all' }" :aria-pressed="changedView === 'all'" @click="changedView = 'all'">{{ t('fileTree.all') }}</button>
</div>
</div>
<div v-if="filesView === 'changed'" class="nav-tools">
<button type="button" class="layout-toggle" :title="changedLayout === 'tree' ? t('fileTree.listView') : t('fileTree.treeView')" :aria-label="changedLayout === 'tree' ? t('fileTree.listView') : t('fileTree.treeView')" @click="toggleChangedLayout">
<svg v-if="changedLayout === 'list'" viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M3 4h2M3 8h2M3 12h2"/><path d="M7.5 4l1.5 1.5L7.5 7"/><path d="M9 5.5h4M9 9.5h3.5M9 12.5h3"/></svg>
<svg v-else viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h10"/></svg>
</button>
</div>
<div class="files-nav-body">
<template v-if="filesView === 'changed'">
<DiffView v-if="changedLayout === 'list'" mode="list" :changes="changes ?? []" :git-info="gitInfo ?? null" @open="pickChanged" />
<ChangedTree v-else :changes="changes ?? []" @open="pickChanged" />
</template>
<FileTree v-else :load-dir="loadDir ?? defaultLoadDir" :changes-by-path="changesByPath ?? {}" :reload-key="fileReloadKey" @select="pickEntry" />
</div>
</div>
<div v-if="!mobile" class="files-divider" aria-hidden="true"></div>
<div v-show="!mobile || filesShowPreview" class="files-content">
<button v-if="mobile" type="button" class="files-back" @click="handleFilesBack">
<span aria-hidden="true">&#8592;</span>
<span class="files-back-label">{{ t('fileTree.backToTree') }}</span>
</button>
<DiffView v-if="selectedDiffPath" mode="detail" :hide-back="true" :changes="changes ?? []" :git-info="gitInfo ?? null" :file-diff="fileDiff ?? []" :selected-diff-path="selectedDiffPath ?? null" :file-diff-loading="fileDiffLoading ?? false" />
<FilePreview v-else-if="selectedFile || previewLoading" :file="selectedFile" :loading="previewLoading" />
<div v-else class="files-empty">{{ filesView === 'changed' ? t('fileTree.selectChanged') : t('fileTree.selectFile') }}</div>
</div>
</template>
</div>
</ViewGroup>
</template>
</SplitLayout>
<div
v-else
ref="panesRef"
class="panes"
:class="{ 'files-layout': active === 'files' }"
:class="{ 'files-layout': active === 'files', 'terminal-layout': active === 'terminal' }"
@scroll.passive="onPanesScroll"
>
<!-- Chat reading column: constrained to a comfortable max width and
@ -628,6 +771,7 @@ onUnmounted(() => {
:status="status"
:thinking="thinking"
:plan-mode="planMode"
:activation-badges="activationBadges"
:models="models"
:skills="skills"
@submit="handleComposerSubmit"
@ -639,6 +783,8 @@ onUnmounted(() => {
@set-permission="emit('setPermission', $event)"
@set-thinking="emit('setThinking', $event)"
@toggle-plan="emit('togglePlan')"
@focus-goal="focusGoal"
@focus-swarm="focusSwarm"
@compact="emit('compact')"
@pick-model="emit('pickModel')"
@select-model="emit('selectModel', $event)"
@ -663,6 +809,9 @@ onUnmounted(() => {
@open-thinking="emit('openThinking', $event)"
@open-compaction="emit('openCompaction', $event)"
/>
<div v-if="(swarms?.length ?? 0) > 0" class="swarm-stack">
<SwarmCard v-for="group in swarms" :key="group.id" :group="group" />
</div>
</template>
</div>
<TasksPane
@ -678,6 +827,11 @@ onUnmounted(() => {
inline
/>
<Terminal
v-else-if="active === 'terminal' && sessionId"
:session-id="sessionId"
/>
<!-- Merged ~/files tab: a navigator (Changed-first list / full tree via the
Changed|All toggle) on the left, an adaptive content pane on the right
(diff for changed files, content preview for unchanged ones). Desktop =
@ -799,6 +953,11 @@ onUnmounted(() => {
line is a quiet footer BELOW it (model/thinking/plan/permission left,
ctx far right). -->
<div ref="dockRef" class="dock" :class="[mobile ? 'align-mobile' : 'align-center']">
<GoalStrip
v-if="goal"
:goal="goal"
:force-expanded="goalExpandSignal"
/>
<!-- A pending question or approval replaces the Composer here both are
the agent blocking on the user, so they share this docked slot. A
question takes priority (it is a direct ask); the approval falls back
@ -825,6 +984,7 @@ onUnmounted(() => {
:status="status"
:thinking="thinking"
:plan-mode="planMode"
:activation-badges="activationBadges"
:models="models"
:skills="skills"
@submit="handleComposerSubmit"
@ -836,6 +996,8 @@ onUnmounted(() => {
@set-permission="emit('setPermission', $event)"
@set-thinking="emit('setThinking', $event)"
@toggle-plan="emit('togglePlan')"
@focus-goal="focusGoal"
@focus-swarm="focusSwarm"
@compact="emit('compact')"
@pick-model="emit('pickModel')"
@select-model="emit('selectModel', $event)"
@ -897,6 +1059,13 @@ onUnmounted(() => {
centered reading column sideways. */
scrollbar-gutter: stable;
}
.layout-root {
flex: 1;
min-height: 0;
}
.group-panes {
height: 100%;
}
/* Chat reading column max-width + alignment. The max-width applies in both
modes; align-left hugs the left gutter, align-center centers in the pane. */
@ -913,6 +1082,12 @@ onUnmounted(() => {
.content-wrap.align-left { margin-left: 0; margin-right: auto; }
/* Mobile: bubbles span the full pane width; no reading-column constraint. */
.content-wrap.align-mobile { max-width: none; }
.swarm-stack {
padding: 0 18px 16px;
}
.content-wrap.align-mobile .swarm-stack {
padding: 0 14px 18px;
}
/* Empty-workspace spacers: push the centred Composer to the vertical middle. */
.empty-spacer { flex: 1; }
@ -991,6 +1166,9 @@ onUnmounted(() => {
flex-direction: row;
overflow: hidden;
}
.panes.terminal-layout {
overflow: hidden;
}
/* Left navigator: the Changed|All toggle + (changed list / full tree). */
.files-nav {

View file

@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { AppGoal } from '../api/types';
const props = defineProps<{ goal: AppGoal; forceExpanded?: number }>();
const expanded = ref(false);
watch(
() => props.forceExpanded,
() => {
if (props.forceExpanded !== undefined) expanded.value = true;
},
);
const tokenPct = computed(() => {
const budget = props.goal.budget.tokenBudget;
if (!budget || budget <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((props.goal.tokensUsed / budget) * 100)));
});
function formatMs(ms: number): string {
const sec = Math.max(0, Math.round(ms / 1000));
const min = Math.floor(sec / 60);
const rem = sec % 60;
if (min <= 0) return `${rem}s`;
if (min < 60) return `${min}m ${rem}s`;
const hour = Math.floor(min / 60);
return `${hour}h ${min % 60}m`;
}
</script>
<template>
<section class="goal-strip" :class="{ expanded }">
<button class="goal-row" type="button" @click="expanded = !expanded">
<span class="goal-kicker">Goal</span>
<span class="goal-objective">{{ goal.objective }}</span>
<span class="goal-status" :class="`status-${goal.status}`">{{ goal.status }}</span>
<span class="goal-progress" aria-hidden="true">
<span class="goal-progress-fill" :style="{ width: `${tokenPct}%` }"></span>
</span>
<svg
class="goal-chevron"
:class="{ open: expanded }"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M6 4l4 4-4 4" />
</svg>
</button>
<div v-if="expanded" class="goal-body">
<div class="goal-full">{{ goal.objective }}</div>
<div v-if="goal.completionCriterion" class="goal-criterion">
<span>Done when</span>
<p>{{ goal.completionCriterion }}</p>
</div>
<div class="goal-stats">
<span>{{ goal.turnsUsed }} turns</span>
<span>{{ goal.tokensUsed.toLocaleString() }} tokens</span>
<span>{{ formatMs(goal.wallClockMs) }}</span>
<span v-if="goal.budget.tokenBudget !== null">{{ tokenPct }}% token budget</span>
</div>
</div>
</section>
</template>
<style scoped>
.goal-strip {
margin: 8px 16px 0;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
overflow: hidden;
}
.goal-row {
width: 100%;
min-height: 36px;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: none;
background: transparent;
color: var(--ink);
font: inherit;
cursor: pointer;
}
.goal-kicker {
flex: none;
color: var(--ok);
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.goal-objective {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ink);
font-size: 12.5px;
}
.goal-status {
flex: none;
border-radius: 999px;
padding: 1px 7px;
border: 1px solid var(--line);
background: var(--bg);
color: var(--dim);
font-family: var(--mono);
font-size: 10.5px;
}
.status-active { color: var(--ok); }
.status-blocked { color: var(--err); }
.status-paused { color: var(--warn); }
.goal-progress {
width: 54px;
height: 4px;
border-radius: 999px;
background: var(--line);
overflow: hidden;
flex: none;
}
.goal-progress-fill {
display: block;
height: 100%;
border-radius: inherit;
background: var(--ok);
}
.goal-chevron {
width: 14px;
height: 14px;
color: var(--muted);
transition: transform 0.12s;
flex: none;
}
.goal-chevron.open {
transform: rotate(90deg);
}
.goal-body {
border-top: 1px solid var(--line);
padding: 10px 12px 12px;
}
.goal-full {
color: var(--ink);
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.goal-criterion {
margin-top: 10px;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
}
.goal-criterion p {
margin: 4px 0 0;
color: var(--dim);
font-family: var(--sans);
font-size: 12px;
line-height: 1.45;
text-transform: none;
}
.goal-stats {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
}
.goal-stats span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px 7px;
background: var(--bg);
}
@media (max-width: 640px) {
.goal-strip {
margin: 8px 10px 0;
}
.goal-progress {
display: none;
}
}
</style>

View file

@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { PaneGroup, PaneLayout } from '../composables/usePaneLayout';
defineOptions({ name: 'SplitLayout' });
const props = defineProps<{ layout: PaneLayout }>();
const emit = defineEmits<{ resize: [splitId: string, sizes: number[]] }>();
defineSlots<{ group(props: { group: PaneGroup }): unknown }>();
const rootRef = ref<HTMLElement | null>(null);
const gridStyle = computed(() => {
if (props.layout.type === 'group') return {};
const tracks = props.layout.sizes.map((size) => `${Math.max(0.1, size)}fr`).join(' 6px ');
return props.layout.dir === 'row'
? { gridTemplateColumns: tracks }
: { gridTemplateRows: tracks };
});
function childGridIndex(index: number): number {
return index * 2 + 1;
}
function handleGridIndex(index: number): number {
return index * 2 + 2;
}
function startResize(event: MouseEvent, index: number): void {
if (props.layout.type === 'group' || !rootRef.value) return;
event.preventDefault();
const split = props.layout;
const start = split.dir === 'row' ? event.clientX : event.clientY;
const rect = rootRef.value.getBoundingClientRect();
const totalPx = split.dir === 'row' ? rect.width : rect.height;
const initial = [...split.sizes];
const totalUnits = initial.reduce((sum, size) => sum + size, 0);
const unitsPerPx = totalPx > 0 ? totalUnits / totalPx : 1;
function onMove(move: MouseEvent): void {
const current = split.dir === 'row' ? move.clientX : move.clientY;
const delta = (current - start) * unitsPerPx;
const next = [...initial];
const left = Math.max(0.2, (initial[index] ?? 1) + delta);
const right = Math.max(0.2, (initial[index + 1] ?? 1) - delta);
next[index] = left;
next[index + 1] = right;
emit('resize', split.id, next);
}
function onUp(): void {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
</script>
<template>
<slot v-if="layout.type === 'group'" name="group" :group="layout" />
<div v-else ref="rootRef" class="split-layout" :class="layout.dir" :style="gridStyle">
<template v-for="(child, index) in layout.children" :key="child.id">
<SplitLayout
class="split-child"
:style="{ gridColumn: layout.dir === 'row' ? childGridIndex(index) : undefined, gridRow: layout.dir === 'col' ? childGridIndex(index) : undefined }"
:layout="child"
@resize="(splitId, sizes) => emit('resize', splitId, sizes)"
>
<template #group="{ group: childGroup }">
<slot name="group" :group="childGroup" />
</template>
</SplitLayout>
<button
v-if="index < layout.children.length - 1"
class="split-handle"
:class="layout.dir"
type="button"
:style="{ gridColumn: layout.dir === 'row' ? handleGridIndex(index) : undefined, gridRow: layout.dir === 'col' ? handleGridIndex(index) : undefined }"
@mousedown="startResize($event, index)"
/>
</template>
</div>
</template>
<style scoped>
.split-layout {
min-width: 0;
min-height: 0;
height: 100%;
display: grid;
}
.split-child {
min-width: 0;
min-height: 0;
}
.split-handle {
border: none;
padding: 0;
background: var(--line);
cursor: col-resize;
}
.split-handle.col {
cursor: row-resize;
}
.split-handle:hover {
background: var(--bd);
}
</style>

View file

@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { AppSubagentPhase } from '../api/types';
import type { SwarmGroup, SwarmMember } from '../composables/swarmGroups';
const props = defineProps<{ group: SwarmGroup }>();
const total = computed(() => props.group.members.length);
const done = computed(() => props.group.counts.completed + props.group.counts.failed);
function phaseLabel(phase: AppSubagentPhase): string {
switch (phase) {
case 'queued': return 'Queued';
case 'working': return 'Working';
case 'suspended': return 'Suspended';
case 'completed': return 'Completed';
case 'failed': return 'Failed';
}
}
function bar(member: SwarmMember): string {
switch (member.phase) {
case 'queued': return '......';
case 'working': return ':::...';
case 'suspended': return '::....';
case 'completed': return '::::::';
case 'failed': return '!!....';
}
}
</script>
<template>
<section class="swarm-card" :id="`swarm-${group.id}`">
<header class="swarm-head">
<div class="swarm-title">
<span class="swarm-mark" aria-hidden="true"></span>
<span>Swarm</span>
</div>
<div class="swarm-count">{{ done }}/{{ total }}</div>
</header>
<div class="swarm-grid">
<article
v-for="member in group.members"
:key="member.id"
class="swarm-member"
:class="`phase-${member.phase}`"
>
<div class="member-top">
<span class="member-name">{{ member.name }}</span>
<span class="member-phase">{{ phaseLabel(member.phase) }}</span>
</div>
<div class="member-mid">
<span class="member-bar">{{ bar(member) }}</span>
<span v-if="member.subagentType" class="member-type">{{ member.subagentType }}</span>
</div>
<div v-if="member.suspendedReason || member.summary" class="member-bottom">
{{ member.suspendedReason || member.summary }}
</div>
</article>
</div>
</section>
</template>
<style scoped>
.swarm-card {
margin: 12px 0;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
overflow: hidden;
}
.swarm-head {
min-height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: var(--panel2);
border-bottom: 1px solid var(--line);
}
.swarm-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 750;
color: var(--ink);
}
.swarm-mark {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--blue);
box-shadow: 0 0 0 4px var(--bluebg);
}
.swarm-count {
border-radius: 999px;
padding: 2px 8px;
background: var(--soft);
color: var(--blue2);
font-family: var(--mono);
font-size: 11px;
}
.swarm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(216px, 1fr));
gap: 8px;
padding: 10px;
}
.swarm-member {
min-width: 0;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg);
padding: 8px 9px;
}
.member-top,
.member-mid {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.member-top {
justify-content: space-between;
}
.member-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12.5px;
font-weight: 650;
}
.member-phase,
.member-type {
flex: none;
color: var(--muted);
font-family: var(--mono);
font-size: 10.5px;
}
.member-mid {
margin-top: 6px;
justify-content: space-between;
}
.member-bar {
color: var(--blue2);
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0;
}
.phase-completed .member-bar { color: var(--ok); }
.phase-failed .member-bar { color: var(--err); }
.phase-suspended .member-bar { color: var(--warn); }
.phase-queued .member-bar { color: var(--muted); }
.member-bottom {
margin-top: 7px;
color: var(--dim);
font-size: 11.5px;
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -13,6 +13,7 @@ const tabs: { key: PaneKey; labelKey: string }[] = [
{ key: 'files', labelKey: 'sidebar.tabFiles' },
{ key: 'tasks', labelKey: 'sidebar.tabTasks' },
{ key: 'todo', labelKey: 'sidebar.tabTodo' },
{ key: 'terminal', labelKey: 'sidebar.tabTerminal' },
];
</script>

View file

@ -0,0 +1,273 @@
<script setup lang="ts">
import '@xterm/xterm/css/xterm.css';
import type { FitAddon as FitAddonType } from '@xterm/addon-fit';
import type { Terminal as XTerm, ITheme } from '@xterm/xterm';
import { computed, nextTick, onMounted, onUnmounted, ref, toRef, watch } from 'vue';
import { useIsDark } from '../composables/useIsDark';
import { useTerminal } from '../composables/useTerminal';
const props = defineProps<{ sessionId: string }>();
const hostRef = ref<HTMLElement | null>(null);
const sessionId = toRef(props, 'sessionId');
const terminalClient = useTerminal(sessionId);
const isDark = useIsDark();
let term: XTerm | null = null;
let fitAddon: FitAddonType | null = null;
let resizeObserver: ResizeObserver | null = null;
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
let disposeOutput: (() => void) | null = null;
let disposeExit: (() => void) | null = null;
const theme = computed<ITheme>(() => {
if (isDark.value) {
return {
background: '#0d1117',
foreground: '#e6edf3',
cursor: '#7aa2ff',
selectionBackground: '#264f78',
black: '#0d1117',
red: '#ff7b72',
green: '#7ee787',
yellow: '#f2cc60',
blue: '#7aa2ff',
magenta: '#d2a8ff',
cyan: '#76e3ea',
white: '#e6edf3',
};
}
return {
background: '#ffffff',
foreground: '#1f2328',
cursor: '#1f6feb',
selectionBackground: '#c8e1ff',
black: '#24292f',
red: '#cf222e',
green: '#116329',
yellow: '#9a6700',
blue: '#0969da',
magenta: '#8250df',
cyan: '#1b7c83',
white: '#f6f8fa',
};
});
function fitAndResize(): void {
if (!term || !fitAddon || !hostRef.value) return;
if (hostRef.value.clientWidth <= 0 || hostRef.value.clientHeight <= 0) return;
try {
fitAddon.fit();
terminalClient.resize(term.cols, term.rows);
} catch {
// xterm-fit can throw while layout is settling; the next resize retries.
}
}
function scheduleFit(): void {
if (resizeTimer !== null) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
fitAndResize();
}, 100);
}
async function initTerminal(): Promise<void> {
if (!hostRef.value || term) return;
const [{ Terminal: XTermCtor }, { FitAddon: FitAddonCtor }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
]);
const next = new XTermCtor({
cursorBlink: true,
convertEol: true,
fontFamily: 'var(--mono)',
fontSize: 13,
lineHeight: 1.25,
scrollback: 4000,
theme: theme.value,
});
const fit = new FitAddonCtor();
next.loadAddon(fit);
next.open(hostRef.value);
next.onData((data) => terminalClient.write(data));
next.onResize(({ cols, rows }) => terminalClient.resize(cols, rows));
term = next;
fitAddon = fit;
disposeOutput = terminalClient.onOutput((data) => {
term?.write(data);
});
disposeExit = terminalClient.onExit((exitCode) => {
term?.writeln('');
term?.writeln(`[process exited${exitCode === null ? '' : ` with code ${exitCode}`}]`);
});
resizeObserver = new ResizeObserver(scheduleFit);
resizeObserver.observe(hostRef.value);
}
async function start(): Promise<void> {
await nextTick();
await initTerminal();
fitAndResize();
await terminalClient.start({ cols: term?.cols, rows: term?.rows });
fitAndResize();
term?.focus();
}
function restart(): void {
term?.reset();
term?.focus();
terminalClient.restart();
}
onMounted(() => {
void start();
});
watch(theme, (nextTheme) => {
if (term) term.options.theme = nextTheme;
});
watch(sessionId, () => {
term?.reset();
if (sessionId.value) void start();
});
onUnmounted(() => {
if (resizeTimer !== null) clearTimeout(resizeTimer);
resizeObserver?.disconnect();
disposeOutput?.();
disposeExit?.();
term?.dispose();
term = null;
fitAddon = null;
});
</script>
<template>
<section class="terminal-pane">
<div class="terminal-toolbar">
<div class="terminal-meta">
<span class="terminal-dot" :class="{ on: terminalClient.connected.value }"></span>
<span v-if="terminalClient.terminal.value">{{ terminalClient.terminal.value.shell }}</span>
<span v-if="terminalClient.terminal.value" class="terminal-cwd">{{ terminalClient.terminal.value.cwd }}</span>
<span v-if="terminalClient.readOnly.value" class="terminal-readonly">exited</span>
</div>
<div class="terminal-actions">
<button type="button" class="terminal-btn" @click="fitAndResize">fit</button>
<button type="button" class="terminal-btn" @click="terminalClient.close">close</button>
<button type="button" class="terminal-btn primary" @click="restart">new</button>
</div>
</div>
<div class="terminal-surface">
<div ref="hostRef" class="terminal-host"></div>
<div v-if="terminalClient.loading.value" class="terminal-overlay">starting terminal...</div>
<div v-else-if="terminalClient.error.value" class="terminal-overlay error">{{ terminalClient.error.value }}</div>
</div>
</section>
</template>
<style scoped>
.terminal-pane {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg);
}
.terminal-toolbar {
flex: none;
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 5px 8px 5px 10px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.terminal-meta {
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
color: var(--dim);
font-family: var(--mono);
font-size: 11px;
}
.terminal-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
flex: none;
}
.terminal-dot.on {
background: var(--ok);
}
.terminal-cwd {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
}
.terminal-readonly {
color: var(--warn);
}
.terminal-actions {
display: flex;
align-items: center;
gap: 5px;
flex: none;
}
.terminal-btn {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg);
color: var(--dim);
font-family: var(--mono);
font-size: 11px;
padding: 3px 7px;
cursor: pointer;
}
.terminal-btn:hover {
background: var(--soft);
color: var(--ink);
}
.terminal-btn.primary {
color: var(--blue2);
}
.terminal-surface {
position: relative;
flex: 1;
min-height: 0;
}
.terminal-host {
position: absolute;
inset: 0;
padding: 8px;
}
.terminal-host :deep(.xterm) {
height: 100%;
}
.terminal-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: color-mix(in srgb, var(--bg) 80%, transparent);
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
text-align: center;
}
.terminal-overlay.error {
color: var(--err);
}
</style>

View file

@ -0,0 +1,102 @@
<script setup lang="ts">
import type { PaneKey, TodoView } from '../types';
import TabBar from './TabBar.vue';
defineProps<{
active: PaneKey;
runningTasks: number;
changesCount?: number;
todos?: TodoView[];
canClose?: boolean;
showCopyConversation?: boolean;
copyConversationCopied?: boolean;
}>();
const emit = defineEmits<{
select: [pane: PaneKey];
split: [dir: 'row' | 'col'];
close: [];
copyConversation: [];
}>();
</script>
<template>
<section class="view-group">
<div class="view-tabs">
<TabBar
:active="active"
:running-tasks="runningTasks"
:changes-count="changesCount"
:todos="todos ?? []"
:show-copy-conversation="showCopyConversation"
:copy-conversation-copied="copyConversationCopied"
@select="emit('select', $event)"
@copy-conversation="emit('copyConversation')"
/>
<div class="view-actions">
<button type="button" class="view-btn" title="Split right" @click="emit('split', 'row')">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.2"/><path d="M8 3v10"/></svg>
</button>
<button type="button" class="view-btn" title="Split down" @click="emit('split', 'col')">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.2"/><path d="M2.5 8h11"/></svg>
</button>
<button v-if="canClose" type="button" class="view-btn" title="Close group" @click="emit('close')">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
</button>
</div>
</div>
<div class="view-body">
<slot />
</div>
</section>
</template>
<style scoped>
.view-group {
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg);
}
.view-tabs {
flex: none;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
border-bottom: 1px solid var(--line);
}
.view-tabs :deep(.tabs) {
border-bottom: none;
}
.view-actions {
display: flex;
align-items: center;
gap: 2px;
padding: 0 6px;
background: var(--panel);
}
.view-btn {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 5px;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.view-btn:hover {
color: var(--ink);
background: var(--panel2);
}
.view-body {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
</style>

View file

@ -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<string, AppTask[]>();
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,

View file

@ -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<AppSubagentPhase, number>;
}
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<AppSubagentPhase, number> {
return {
queued: 0,
working: 0,
suspended: 0,
completed: 0,
failed: 0,
};
}
export function buildSwarmGroups(tasks: AppTask[]): SwarmGroup[] {
const buckets = new Map<string, SwarmMember[]>();
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 };
}

View file

@ -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<typeof toAppEvent>, 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<typeof toAppEvent>, 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<void> {
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<boolean>(() => {
return rawState.sendingBySession[sid] ?? false;
});
const activeAppTasks = computed<AppTask[]>(() => {
const sid = rawState.activeSessionId;
if (!sid) return [];
return rawState.tasksBySession[sid] ?? [];
});
const turns = computed<ChatTurn[]>(() => {
const sid = rawState.activeSessionId;
if (!sid) return [];
@ -1087,13 +1099,20 @@ const turns = computed<ChatTurn[]>(() => {
approvals,
(fileId) => getKimiWebApi().getFileUrl(fileId),
activity.value !== 'idle',
activeAppTasks.value,
);
});
const tasks = computed<TaskItem[]>(() => {
return activeAppTasks.value.map(toUiTask);
});
const swarms = computed<SwarmGroup[]>(() => buildSwarmGroups(activeAppTasks.value));
const goal = computed<AppGoal | null>(() => {
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<PermissionMode>(() => rawState.permission);
const thinking = computed<ThinkingLevel>(() => rawState.thinking);
const planMode = computed<boolean>(() => rawState.planMode);
const activationBadges = computed<ActivationBadges>(() => {
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,

View file

@ -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<string, unknown>;
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<PaneLayout>(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 };
}

View file

@ -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<string>) {
const terminal = ref<AppTerminal | null>(null);
const loading = ref(false);
const error = ref<string | null>(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<void> {
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<void> {
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,
};
}

View file

@ -25,6 +25,7 @@ export default {
tabFiles: 'files',
tabTasks: 'tasks',
tabTodo: 'todo',
tabTerminal: 'terminal',
noSessions: 'No conversations yet',
showMore: 'Show more ({count})',
} as const;

View file

@ -25,6 +25,7 @@ export default {
tabFiles: '文件',
tabTasks: '后台任务',
tabTodo: '待办',
tabTerminal: '终端',
noSessions: '暂无对话',
showMore: '展开更多 ({count})',
};

View file

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

View file

@ -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']);
});
});

View file

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

View file

@ -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<AppTask> & Pick<AppTask, 'id'>): 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 });
});
});

22
pnpm-lock.yaml generated
View file

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

View file

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