mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
be7ab774ee
commit
f5a7f21c68
34 changed files with 2930 additions and 44 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
|
|
|
|||
148
apps/kimi-web/src/components/AgentCard.vue
Normal file
148
apps/kimi-web/src/components/AgentCard.vue
Normal 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>
|
||||
98
apps/kimi-web/src/components/AgentGroup.vue
Normal file
98
apps/kimi-web/src/components/AgentGroup.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">←</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 {
|
||||
|
|
|
|||
196
apps/kimi-web/src/components/GoalStrip.vue
Normal file
196
apps/kimi-web/src/components/GoalStrip.vue
Normal 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>
|
||||
110
apps/kimi-web/src/components/SplitLayout.vue
Normal file
110
apps/kimi-web/src/components/SplitLayout.vue
Normal 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>
|
||||
165
apps/kimi-web/src/components/SwarmCard.vue
Normal file
165
apps/kimi-web/src/components/SwarmCard.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
273
apps/kimi-web/src/components/Terminal.vue
Normal file
273
apps/kimi-web/src/components/Terminal.vue
Normal 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>
|
||||
102
apps/kimi-web/src/components/ViewGroup.vue
Normal file
102
apps/kimi-web/src/components/ViewGroup.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
83
apps/kimi-web/src/composables/swarmGroups.ts
Normal file
83
apps/kimi-web/src/composables/swarmGroups.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
155
apps/kimi-web/src/composables/usePaneLayout.ts
Normal file
155
apps/kimi-web/src/composables/usePaneLayout.ts
Normal 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 };
|
||||
}
|
||||
142
apps/kimi-web/src/composables/useTerminal.ts
Normal file
142
apps/kimi-web/src/composables/useTerminal.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ export default {
|
|||
tabFiles: 'files',
|
||||
tabTasks: 'tasks',
|
||||
tabTodo: 'todo',
|
||||
tabTerminal: 'terminal',
|
||||
noSessions: 'No conversations yet',
|
||||
showMore: 'Show more ({count})',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export default {
|
|||
tabFiles: '文件',
|
||||
tabTasks: '后台任务',
|
||||
tabTodo: '待办',
|
||||
tabTerminal: '终端',
|
||||
noSessions: '暂无对话',
|
||||
showMore: '展开更多 ({count})',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
94
apps/kimi-web/test/agent-group-turns.test.ts
Normal file
94
apps/kimi-web/test/agent-group-turns.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
160
apps/kimi-web/test/subagent-goal.test.ts
Normal file
160
apps/kimi-web/test/subagent-goal.test.ts
Normal 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,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
48
apps/kimi-web/test/swarm-groups.test.ts
Normal file
48
apps/kimi-web/test/swarm-groups.test.ts
Normal 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
22
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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": "-"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue