mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat(remote-control): 优化 Web 展示、状态同步与桥接控制流程 (#288)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
This commit is contained in:
@@ -3,8 +3,11 @@ import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { notifyAutomationStateChanged } from 'src/utils/sessionState.js'
|
||||
import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js'
|
||||
|
||||
const SLEEP_WAKE_CHECK_INTERVAL_MS = 500
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
duration_seconds: z
|
||||
@@ -19,6 +22,36 @@ type SleepInput = z.infer<InputSchema>
|
||||
|
||||
type SleepOutput = { slept_seconds: number; interrupted: boolean }
|
||||
|
||||
function isProactiveAutomationEnabled(): boolean {
|
||||
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
return mod.isProactiveActive()
|
||||
}
|
||||
|
||||
function isProactiveSleepAllowed(): boolean {
|
||||
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
return mod.isProactiveActive()
|
||||
}
|
||||
|
||||
function hasQueuedWakeSignal(): boolean {
|
||||
const queue =
|
||||
require('src/utils/messageQueueManager.js') as typeof import('src/utils/messageQueueManager.js')
|
||||
return queue.hasCommandsInQueue()
|
||||
}
|
||||
|
||||
function shouldInterruptSleep(): boolean {
|
||||
return !isProactiveSleepAllowed() || hasQueuedWakeSignal()
|
||||
}
|
||||
|
||||
export const SleepTool = buildTool({
|
||||
name: SLEEP_TOOL_NAME,
|
||||
searchHint: 'wait pause sleep rest idle duration timer',
|
||||
@@ -42,6 +75,9 @@ export const SleepTool = buildTool({
|
||||
isReadOnly() {
|
||||
return true
|
||||
},
|
||||
interruptBehavior() {
|
||||
return 'cancel'
|
||||
},
|
||||
|
||||
userFacingName() {
|
||||
return SLEEP_TOOL_NAME
|
||||
@@ -67,53 +103,84 @@ export const SleepTool = buildTool({
|
||||
},
|
||||
|
||||
async call(input: SleepInput, context) {
|
||||
// Refuse to sleep when proactive mode is off — prevents the model from
|
||||
// re-issuing Sleep after an interruption caused by /proactive disable.
|
||||
if (feature('PROACTIVE') || feature('KAIROS')) {
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
if (!mod.isProactiveActive()) {
|
||||
return {
|
||||
data: {
|
||||
slept_seconds: 0,
|
||||
interrupted: true,
|
||||
},
|
||||
}
|
||||
// Don't enter sleep if proactive was disabled or new work arrived while
|
||||
// the model was deciding to wait.
|
||||
if (shouldInterruptSleep()) {
|
||||
return {
|
||||
data: {
|
||||
slept_seconds: 0,
|
||||
interrupted: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const { duration_seconds } = input
|
||||
const startTime = Date.now()
|
||||
const sleepUntil = startTime + duration_seconds * 1000
|
||||
|
||||
if (isProactiveAutomationEnabled()) {
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: null,
|
||||
sleep_until: sleepUntil,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, duration_seconds * 1000)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let wakeCheck: ReturnType<typeof setInterval> | null = null
|
||||
let settled = false
|
||||
|
||||
const cleanup = () => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
if (wakeCheck !== null) {
|
||||
clearInterval(wakeCheck)
|
||||
wakeCheck = null
|
||||
}
|
||||
context.abortController.signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
|
||||
const interrupt = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(new Error('interrupted'))
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
interrupt()
|
||||
}
|
||||
|
||||
timer = setTimeout(finish, duration_seconds * 1000)
|
||||
|
||||
// Abort via user interrupt
|
||||
context.abortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
clearInterval(proactiveCheck)
|
||||
reject(new Error('interrupted'))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
if (context.abortController.signal.aborted) {
|
||||
interrupt()
|
||||
return
|
||||
}
|
||||
context.abortController.signal.addEventListener('abort', onAbort, {
|
||||
once: true,
|
||||
})
|
||||
|
||||
// Poll proactive state — if deactivated mid-sleep, interrupt early
|
||||
// so the user doesn't have to wait for the full duration.
|
||||
const proactiveCheck =
|
||||
feature('PROACTIVE') || feature('KAIROS')
|
||||
? setInterval(() => {
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
if (!mod.isProactiveActive()) {
|
||||
clearTimeout(timer)
|
||||
clearInterval(proactiveCheck)
|
||||
reject(new Error('interrupted'))
|
||||
}
|
||||
}, 500)
|
||||
: (null as unknown as ReturnType<typeof setInterval>)
|
||||
// Poll proactive state and the shared command queue so new work can
|
||||
// wake Sleep without waiting for the full duration.
|
||||
wakeCheck = setInterval(() => {
|
||||
if (shouldInterruptSleep()) {
|
||||
interrupt()
|
||||
}
|
||||
}, SLEEP_WAKE_CHECK_INTERVAL_MS)
|
||||
})
|
||||
return {
|
||||
data: {
|
||||
@@ -129,6 +196,17 @@ export const SleepTool = buildTool({
|
||||
interrupted: true,
|
||||
},
|
||||
}
|
||||
} finally {
|
||||
notifyAutomationStateChanged(
|
||||
isProactiveAutomationEnabled()
|
||||
? {
|
||||
enabled: true,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { SleepTool } from '../SleepTool'
|
||||
import {
|
||||
enqueue,
|
||||
getCommandQueue,
|
||||
resetCommandQueue,
|
||||
} from 'src/utils/messageQueueManager.js'
|
||||
|
||||
describe('SleepTool', () => {
|
||||
beforeEach(() => {
|
||||
resetCommandQueue()
|
||||
})
|
||||
|
||||
test('declares cancel interrupt behavior', () => {
|
||||
expect(SleepTool.interruptBehavior()).toBe('cancel')
|
||||
})
|
||||
|
||||
test('wakes early when queued work arrives', async () => {
|
||||
const sleepPromise = SleepTool.call(
|
||||
{ duration_seconds: 10 },
|
||||
{ abortController: new AbortController() } as any,
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
enqueue({
|
||||
value: 'wake up',
|
||||
mode: 'prompt',
|
||||
})
|
||||
}, 20)
|
||||
|
||||
const result = await sleepPromise
|
||||
|
||||
expect(result.data.interrupted).toBe(true)
|
||||
expect(result.data.slept_seconds).toBeLessThan(10)
|
||||
expect(getCommandQueue()).toHaveLength(1)
|
||||
expect(getCommandQueue()[0]).toMatchObject({
|
||||
value: 'wake up',
|
||||
mode: 'prompt',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -678,6 +678,44 @@ describe("Web Session Routes", () => {
|
||||
expect(getRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id — includes automation_state snapshot when worker metadata has it", async () => {
|
||||
const createRes = await app.request("/v1/code/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const {
|
||||
session: { id },
|
||||
} = await createRes.json();
|
||||
storeBindSession(id, "user-1");
|
||||
|
||||
await app.request(`/v1/code/sessions/${id}/worker`, {
|
||||
method: "PUT",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
worker_epoch: 1,
|
||||
external_metadata: {
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 123456,
|
||||
sleep_until: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const getRes = await app.request(`/web/sessions/${toWebSessionId(id)}?uuid=user-1`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const body = await getRes.json();
|
||||
expect(body.automation_state).toEqual({
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 123456,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id — 403 for non-owner", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
@@ -704,6 +742,35 @@ describe("Web Session Routes", () => {
|
||||
expect(body.events).toEqual([]);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id/history — returns task_state snapshots", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
publishSessionEvent(
|
||||
id,
|
||||
"task_state",
|
||||
{
|
||||
task_list_id: "team-alpha",
|
||||
tasks: [{ id: "1", subject: "Investigate", status: "pending" }],
|
||||
},
|
||||
"inbound",
|
||||
);
|
||||
|
||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
|
||||
expect(histRes.status).toBe(200);
|
||||
const body = await histRes.json();
|
||||
expect(body.events).toHaveLength(1);
|
||||
expect(body.events[0]?.type).toBe("task_state");
|
||||
expect(body.events[0]?.payload.task_list_id).toBe("team-alpha");
|
||||
expect(body.events[0]?.payload.tasks).toEqual([
|
||||
{ id: "1", subject: "Investigate", status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id and history — supports compat code session IDs", async () => {
|
||||
const codeSession = storeCreateSession({ idPrefix: "cse_" });
|
||||
storeBindSession(codeSession.id, "user-1");
|
||||
@@ -1218,7 +1285,15 @@ describe("V2 Worker Events Routes", () => {
|
||||
body: JSON.stringify({
|
||||
worker_epoch: 1,
|
||||
worker_status: "running",
|
||||
external_metadata: { permission_mode: "default" },
|
||||
external_metadata: {
|
||||
permission_mode: "default",
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(putRes.status).toBe(200);
|
||||
@@ -1230,6 +1305,21 @@ describe("V2 Worker Events Routes", () => {
|
||||
const body = await getRes.json();
|
||||
expect(body.worker.worker_status).toBe("running");
|
||||
expect(body.worker.external_metadata.permission_mode).toBe("default");
|
||||
expect(body.worker.external_metadata.automation_state).toEqual({
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
});
|
||||
|
||||
const events = getEventBus(id).getEventsSince(0);
|
||||
expect(events.some((event) => event.type === "automation_state")).toBe(true);
|
||||
expect(events.at(-1)?.payload).toEqual({
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /v1/code/sessions/:id/worker/heartbeat — updates heartbeat", async () => {
|
||||
@@ -1284,6 +1374,123 @@ describe("V2 Worker Events Routes", () => {
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web permission approvals to control_response", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(streamRes.status).toBe(200);
|
||||
|
||||
const reader = streamRes.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
if (!reader) return;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "permission_response",
|
||||
approved: true,
|
||||
request_id: "req-1",
|
||||
}),
|
||||
});
|
||||
expect(controlRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"permission_response\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
|
||||
expect(frame).toContain("\"request_id\":\"req-1\"");
|
||||
expect(frame).toContain("\"behavior\":\"allow\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web plan rejection feedback to deny control_response", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(streamRes.status).toBe(200);
|
||||
|
||||
const reader = streamRes.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
if (!reader) return;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "permission_response",
|
||||
approved: false,
|
||||
request_id: "req-2",
|
||||
message: "Need more detail",
|
||||
}),
|
||||
});
|
||||
expect(controlRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"permission_response\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
|
||||
expect(frame).toContain("\"request_id\":\"req-2\"");
|
||||
expect(frame).toContain("\"subtype\":\"error\"");
|
||||
expect(frame).toContain("\"behavior\":\"deny\"");
|
||||
expect(frame).toContain("\"message\":\"Need more detail\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web interrupts to control_request", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(streamRes.status).toBe(200);
|
||||
|
||||
const reader = streamRes.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
if (!reader) return;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const interruptRes = await app.request(`/web/sessions/${id}/interrupt?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
expect(interruptRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"interrupt\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_request\"");
|
||||
expect(frame).toContain("\"subtype\":\"interrupt\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => {
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
|
||||
@@ -353,6 +353,14 @@ describe("Transport Service", () => {
|
||||
expect(result.uuid).toBe("msg_123");
|
||||
});
|
||||
|
||||
test("preserves isSynthetic field", () => {
|
||||
const result = normalizePayload("user", {
|
||||
content: "scheduled job: refresh analytics cache",
|
||||
isSynthetic: true,
|
||||
});
|
||||
expect(result.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("uses name as tool_name fallback", () => {
|
||||
const result = normalizePayload("tool", { name: "Read" });
|
||||
expect(result.tool_name).toBe("Read");
|
||||
@@ -370,6 +378,28 @@ describe("Transport Service", () => {
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
test("preserves task_state fields", () => {
|
||||
const result = normalizePayload("task_state", {
|
||||
task_list_id: "team-alpha",
|
||||
tasks: [{ id: "1", subject: "Task 1", status: "pending" }],
|
||||
});
|
||||
expect(result.task_list_id).toBe("team-alpha");
|
||||
expect(result.tasks).toEqual([
|
||||
{ id: "1", subject: "Task 1", status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves status metadata for conversation reset events", () => {
|
||||
const result = normalizePayload("status", {
|
||||
status: "conversation_cleared",
|
||||
subtype: "status",
|
||||
message: "conversation_cleared",
|
||||
});
|
||||
expect(result.status).toBe("conversation_cleared");
|
||||
expect(result.subtype).toBe("status");
|
||||
expect(result.message).toBe("conversation_cleared");
|
||||
});
|
||||
|
||||
test("handles undefined payload", () => {
|
||||
const result = normalizePayload("user", undefined);
|
||||
expect(result.content).toBe("");
|
||||
|
||||
@@ -69,6 +69,19 @@ describe("ws-handler", () => {
|
||||
expect((events[0] as any).direction).toBe("inbound");
|
||||
});
|
||||
|
||||
test("preserves synthetic flag on inbound user messages", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", {
|
||||
message: { role: "user", content: "scheduled job: refresh analytics cache" },
|
||||
uuid: "u_synth",
|
||||
isSynthetic: true,
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).payload.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("derives type from message.role for assistant messages", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
@@ -163,6 +176,24 @@ describe("ws-handler", () => {
|
||||
expect(msg.type).toBe("user");
|
||||
});
|
||||
|
||||
test("replays synthetic user metadata back to the bridge", () => {
|
||||
const bus = getEventBus("s3");
|
||||
bus.publish({
|
||||
id: "e1",
|
||||
sessionId: "s3",
|
||||
type: "user",
|
||||
payload: { content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "s3");
|
||||
|
||||
const msg = JSON.parse(ws.getSentData()[0]);
|
||||
expect(msg.type).toBe("user");
|
||||
expect(msg.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("replaces existing connection for same session", () => {
|
||||
const ws1 = createMockWs();
|
||||
const ws2 = createMockWs();
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
||||
import {
|
||||
automationStatesEqual,
|
||||
getAutomationStateEventPayload,
|
||||
} from "../../services/automationState";
|
||||
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -33,6 +39,9 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const prevAutomationState = getAutomationStateEventPayload(
|
||||
storeGetSessionWorker(sessionId)?.externalMetadata,
|
||||
);
|
||||
if (body.worker_status) {
|
||||
updateSessionStatus(sessionId, body.worker_status);
|
||||
} else {
|
||||
@@ -44,6 +53,17 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
externalMetadata: body.external_metadata,
|
||||
requiresActionDetails: body.requires_action_details,
|
||||
});
|
||||
const nextAutomationState = getAutomationStateEventPayload(worker.externalMetadata);
|
||||
|
||||
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
|
||||
getEventBus(sessionId).publish({
|
||||
id: uuid(),
|
||||
sessionId,
|
||||
type: "automation_state",
|
||||
payload: nextAutomationState,
|
||||
direction: "inbound",
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
status: "ok",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { uuidAuth } from "../../auth/middleware";
|
||||
import { getAutomationStateSnapshot } from "../../services/automationState";
|
||||
import {
|
||||
createSession,
|
||||
getSession,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
resolveOwnedWebSessionId,
|
||||
toWebSessionResponse,
|
||||
} from "../../services/session";
|
||||
import { storeBindSession } from "../../store";
|
||||
import { storeBindSession, storeGetSessionWorker } from "../../store";
|
||||
import { createWorkItem } from "../../services/work-dispatch";
|
||||
import { createSSEStream } from "../../transport/sse-writer";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
@@ -68,7 +69,13 @@ app.get("/sessions/:id", uuidAuth, async (c) => {
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
return c.json(toWebSessionResponse(session), 200);
|
||||
const worker = storeGetSessionWorker(sessionId);
|
||||
const automationState = getAutomationStateSnapshot(worker?.externalMetadata);
|
||||
const response = toWebSessionResponse(session);
|
||||
return c.json(
|
||||
automationState === undefined ? response : { ...response, automation_state: automationState },
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
/** GET /web/sessions/:id/history — Historical events for session */
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { AutomationStateResponse } from "../types/api";
|
||||
|
||||
const DISABLED_AUTOMATION_STATE: AutomationStateResponse = Object.freeze({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
|
||||
function cloneAutomationState(state: AutomationStateResponse): AutomationStateResponse {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
function normalizeAutomationState(raw: unknown): AutomationStateResponse {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
||||
}
|
||||
|
||||
const state = raw as Record<string, unknown>;
|
||||
return {
|
||||
enabled: state.enabled === true,
|
||||
phase: state.phase === "standby" || state.phase === "sleeping" ? state.phase : null,
|
||||
next_tick_at: typeof state.next_tick_at === "number" ? state.next_tick_at : null,
|
||||
sleep_until: typeof state.sleep_until === "number" ? state.sleep_until : null,
|
||||
};
|
||||
}
|
||||
|
||||
function readAutomationStateValue(metadata: Record<string, unknown> | null | undefined): unknown {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, "automation_state")) {
|
||||
return undefined;
|
||||
}
|
||||
return metadata.automation_state;
|
||||
}
|
||||
|
||||
export function getAutomationStateSnapshot(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): AutomationStateResponse | undefined {
|
||||
const raw = readAutomationStateValue(metadata);
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeAutomationState(raw);
|
||||
}
|
||||
|
||||
export function getAutomationStateEventPayload(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): AutomationStateResponse {
|
||||
return getAutomationStateSnapshot(metadata) ?? cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
||||
}
|
||||
|
||||
export function automationStatesEqual(
|
||||
a: AutomationStateResponse,
|
||||
b: AutomationStateResponse,
|
||||
): boolean {
|
||||
return (
|
||||
a.enabled === b.enabled &&
|
||||
a.phase === b.phase &&
|
||||
a.next_tick_at === b.next_tick_at &&
|
||||
a.sleep_until === b.sleep_until
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,9 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
|
||||
};
|
||||
|
||||
if (typeof p.uuid === "string" && p.uuid) normalized.uuid = p.uuid;
|
||||
if (typeof p.isSynthetic === "boolean") normalized.isSynthetic = p.isSynthetic;
|
||||
if (typeof p.status === "string") normalized.status = p.status;
|
||||
if (typeof p.subtype === "string") normalized.subtype = p.subtype;
|
||||
|
||||
// Preserve tool fields
|
||||
if (p.tool_name) normalized.tool_name = p.tool_name;
|
||||
@@ -68,6 +71,12 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
|
||||
// Preserve message field for backward compat
|
||||
if (p.message) normalized.message = p.message;
|
||||
|
||||
if (type === "task_state") {
|
||||
if (typeof p.task_list_id === "string") normalized.task_list_id = p.task_list_id;
|
||||
if (typeof p.taskListId === "string") normalized.taskListId = p.taskListId;
|
||||
if (Array.isArray(p.tasks)) normalized.tasks = p.tasks;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
|
||||
/**
|
||||
* Convert an internal session event into the SDK/control message shape that
|
||||
* bridge workers consume on both the legacy WS path and the v2 worker SSE path.
|
||||
*/
|
||||
export function toClientPayload(event: SessionEvent): Record<string, unknown> {
|
||||
const payload = event.payload as Record<string, unknown> | null;
|
||||
const messageUuid =
|
||||
typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
|
||||
|
||||
if (event.type === "user" || event.type === "user_message") {
|
||||
return {
|
||||
type: "user",
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
...(payload?.isSynthetic === true ? { isSynthetic: true } : {}),
|
||||
message: {
|
||||
role: "user",
|
||||
content: payload?.content ?? payload?.message ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "permission_response" || event.type === "control_response") {
|
||||
const approved = !!payload?.approved;
|
||||
const existingResponse = payload?.response as Record<string, unknown> | undefined;
|
||||
if (existingResponse) {
|
||||
return { type: "control_response", response: existingResponse };
|
||||
}
|
||||
|
||||
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
|
||||
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
|
||||
const feedbackMessage = payload?.message as string | undefined;
|
||||
|
||||
return {
|
||||
type: "control_response",
|
||||
response: {
|
||||
subtype: approved ? "success" : "error",
|
||||
request_id: payload?.request_id ?? "",
|
||||
...(approved
|
||||
? {
|
||||
response: {
|
||||
behavior: "allow" as const,
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
error: "Permission denied by user",
|
||||
response: { behavior: "deny" as const },
|
||||
...(feedbackMessage ? { message: feedbackMessage } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "interrupt") {
|
||||
return {
|
||||
type: "control_request",
|
||||
request_id: event.id,
|
||||
request: { subtype: "interrupt" },
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "control_request") {
|
||||
return {
|
||||
type: "control_request",
|
||||
request_id: payload?.request_id ?? event.id,
|
||||
request: payload?.request ?? payload,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: event.type,
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: payload,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { log, error as logError } from "../logger";
|
||||
import type { Context } from "hono";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { getEventBus } from "./event-bus";
|
||||
import { toClientPayload } from "./client-payload";
|
||||
|
||||
export interface SSEWriter {
|
||||
send(event: SessionEvent): void;
|
||||
@@ -118,6 +119,15 @@ export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
|
||||
}
|
||||
|
||||
function toWorkerClientPayload(event: SessionEvent): Record<string, unknown> {
|
||||
if (
|
||||
event.type === "permission_response" ||
|
||||
event.type === "control_response" ||
|
||||
event.type === "control_request" ||
|
||||
event.type === "interrupt"
|
||||
) {
|
||||
return toClientPayload(event);
|
||||
}
|
||||
|
||||
const normalized =
|
||||
event.payload && typeof event.payload === "object"
|
||||
? (event.payload as Record<string, unknown>)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getEventBus } from "./event-bus";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { publishSessionEvent } from "../services/transport";
|
||||
import { log, error as logError } from "../logger";
|
||||
import { toClientPayload } from "./client-payload";
|
||||
|
||||
// Per-connection cleanup, keyed by sessionId (only one WS per session)
|
||||
interface CleanupEntry {
|
||||
@@ -24,75 +25,9 @@ const SERVER_KEEPALIVE_INTERVAL_MS = 60_000;
|
||||
* Convert internal EventBus event -> SDK message for bridge client.
|
||||
*/
|
||||
function toSDKMessage(event: SessionEvent): string {
|
||||
const payload = event.payload as Record<string, unknown> | null;
|
||||
const messageUuid = typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
|
||||
|
||||
let msg: Record<string, unknown>;
|
||||
|
||||
if (event.type === "user" || event.type === "user_message") {
|
||||
msg = {
|
||||
type: "user",
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: {
|
||||
role: "user",
|
||||
content: payload?.content ?? payload?.message ?? "",
|
||||
},
|
||||
};
|
||||
} else if (event.type === "permission_response" || event.type === "control_response") {
|
||||
const approved = !!payload?.approved;
|
||||
const existingResponse = payload?.response as Record<string, unknown> | undefined;
|
||||
if (existingResponse) {
|
||||
msg = { type: "control_response", response: existingResponse };
|
||||
} else {
|
||||
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
|
||||
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
|
||||
const feedbackMessage = payload?.message as string | undefined;
|
||||
msg = {
|
||||
type: "control_response",
|
||||
response: {
|
||||
subtype: approved ? "success" : "error",
|
||||
request_id: payload?.request_id ?? "",
|
||||
...(approved
|
||||
? {
|
||||
response: {
|
||||
behavior: "allow" as const,
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
error: "Permission denied by user",
|
||||
response: { behavior: "deny" as const },
|
||||
...(feedbackMessage ? { message: feedbackMessage } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (event.type === "interrupt") {
|
||||
msg = {
|
||||
type: "control_request",
|
||||
request_id: event.id,
|
||||
request: { subtype: "interrupt" },
|
||||
};
|
||||
} else if (event.type === "control_request") {
|
||||
msg = {
|
||||
type: "control_request",
|
||||
request_id: payload?.request_id ?? event.id,
|
||||
request: payload?.request ?? payload,
|
||||
};
|
||||
} else {
|
||||
msg = {
|
||||
type: event.type,
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: payload,
|
||||
};
|
||||
}
|
||||
|
||||
// NDJSON format: each message MUST end with \n so the child process's
|
||||
// line-based parser can split messages correctly.
|
||||
return JSON.stringify(msg) + "\n";
|
||||
return JSON.stringify(toClientPayload(event)) + "\n";
|
||||
}
|
||||
|
||||
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
|
||||
@@ -236,7 +171,11 @@ export function ingestBridgeMessage(sessionId: string, msg: Record<string, unkno
|
||||
}
|
||||
payload = { message: msg.message, uuid: msg.uuid, content: text };
|
||||
} else if (eventType === "user" || eventType === "system") {
|
||||
payload = { message: msg.message, uuid: msg.uuid };
|
||||
payload = {
|
||||
message: msg.message,
|
||||
uuid: msg.uuid,
|
||||
...(typeof msg.isSynthetic === "boolean" ? { isSynthetic: msg.isSynthetic } : {}),
|
||||
};
|
||||
} else if (eventType === "control_request") {
|
||||
payload = { request_id: msg.request_id, request: msg.request };
|
||||
} else if (eventType === "control_response") {
|
||||
|
||||
@@ -70,6 +70,14 @@ export interface SessionResponse {
|
||||
username: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
automation_state?: AutomationStateResponse;
|
||||
}
|
||||
|
||||
export interface AutomationStateResponse {
|
||||
enabled: boolean;
|
||||
phase: "standby" | "sleeping" | null;
|
||||
next_tick_at: number | null;
|
||||
sleep_until: number | null;
|
||||
}
|
||||
|
||||
// --- v2 Code Sessions ---
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ControlRequest extends SDKMessage {
|
||||
export type SessionEventType =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "automation_state"
|
||||
| "permission_request"
|
||||
| "permission_response"
|
||||
| "control_request"
|
||||
@@ -49,6 +50,7 @@ export type SessionEventType =
|
||||
export interface NormalizedEventPayload {
|
||||
content: string;
|
||||
raw?: unknown;
|
||||
isSynthetic?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,24 @@
|
||||
*/
|
||||
import { getUuid, setUuid, apiBind, apiFetchSessions, apiFetchAllSessions, apiFetchEnvironments, apiFetchSession, apiFetchSessionHistory, apiSendEvent, apiSendControl, apiInterrupt, apiCreateSession } from "./api.js";
|
||||
import { connectSSE, disconnectSSE } from "./sse.js";
|
||||
import { appendEvent, showLoading, isLoading, removeLoading, resetReplayState, renderReplayPendingRequests } from "./render.js";
|
||||
import {
|
||||
appendEvent,
|
||||
getActivityMode,
|
||||
removeLoading,
|
||||
resetReplayState,
|
||||
renderReplayPendingRequests,
|
||||
setAutomationActivity,
|
||||
showLoading,
|
||||
} from "./render.js";
|
||||
import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js";
|
||||
import {
|
||||
createAutomationState,
|
||||
getAutomationActivity,
|
||||
getAutomationIndicator,
|
||||
reduceAutomationState,
|
||||
renderAutomationIcon,
|
||||
shouldPulseAutomationIndicator,
|
||||
} from "./automation.js";
|
||||
import { esc, formatTime, statusClass, isClosedSessionStatus } from "./utils.js";
|
||||
|
||||
// ============================================================
|
||||
@@ -16,6 +32,8 @@ let currentSessionId = null;
|
||||
let currentSessionStatus = null;
|
||||
let dashboardInterval = null;
|
||||
let cachedEnvs = [];
|
||||
let automationState = createAutomationState();
|
||||
let automationPulseTimer = null;
|
||||
|
||||
function generateMessageUuid() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
@@ -24,6 +42,82 @@ function generateMessageUuid() {
|
||||
return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function renderAutomationIndicator() {
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (!indicatorEl) return;
|
||||
|
||||
const indicator = getAutomationIndicator(automationState);
|
||||
if (!indicator.visible) {
|
||||
indicatorEl.className = "automation-pill hidden";
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.innerHTML = "";
|
||||
indicatorEl.removeAttribute("title");
|
||||
return;
|
||||
}
|
||||
|
||||
indicatorEl.className = `automation-pill automation-pill-${indicator.tone}`;
|
||||
if (indicatorEl.dataset.pulsing === "true") {
|
||||
indicatorEl.classList.add("is-pulsing");
|
||||
}
|
||||
indicatorEl.innerHTML = `
|
||||
${renderAutomationIcon(indicator.iconVariant, { className: "automation-pill-icon" })}
|
||||
<span class="automation-pill-label">${esc(indicator.label)}</span>
|
||||
`;
|
||||
indicatorEl.title = indicator.title;
|
||||
}
|
||||
|
||||
function syncAutomationUI() {
|
||||
renderAutomationIndicator();
|
||||
setAutomationActivity(getAutomationActivity(automationState));
|
||||
}
|
||||
|
||||
function stopAutomationPulse() {
|
||||
if (automationPulseTimer) {
|
||||
clearTimeout(automationPulseTimer);
|
||||
automationPulseTimer = null;
|
||||
}
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (indicatorEl) {
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.classList.remove("is-pulsing");
|
||||
}
|
||||
}
|
||||
|
||||
function pulseAutomationIndicator() {
|
||||
if (!getAutomationIndicator(automationState).visible) return;
|
||||
|
||||
stopAutomationPulse();
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (!indicatorEl) return;
|
||||
|
||||
indicatorEl.dataset.pulsing = "true";
|
||||
indicatorEl.classList.add("is-pulsing");
|
||||
automationPulseTimer = setTimeout(() => {
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.classList.remove("is-pulsing");
|
||||
automationPulseTimer = null;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function resetAutomationIndicator() {
|
||||
automationState = createAutomationState();
|
||||
stopAutomationPulse();
|
||||
syncAutomationUI();
|
||||
}
|
||||
|
||||
function applyAutomationEvent(event, { replay = false } = {}) {
|
||||
automationState = reduceAutomationState(automationState, event);
|
||||
syncAutomationUI();
|
||||
if (!replay && shouldPulseAutomationIndicator(event)) {
|
||||
pulseAutomationIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
function applyAutomationSnapshot(snapshot) {
|
||||
if (snapshot === undefined) return;
|
||||
applyAutomationEvent({ type: "automation_state", payload: snapshot }, { replay: true });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Router
|
||||
// ============================================================
|
||||
@@ -75,7 +169,7 @@ function applySessionStatus(status) {
|
||||
|
||||
if (closed) {
|
||||
removeLoading();
|
||||
window.__updateActionBtn?.(false);
|
||||
window.__updateActionBtn?.("idle");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +180,7 @@ function handleSessionEvent(event) {
|
||||
disconnectSSE();
|
||||
}
|
||||
}
|
||||
applyAutomationEvent(event);
|
||||
appendEvent(event);
|
||||
}
|
||||
|
||||
@@ -104,7 +199,9 @@ async function syncClosedSessionState(err, actionLabel) {
|
||||
const session = await apiFetchSession(currentSessionId);
|
||||
applySessionStatus(session.status);
|
||||
if (isClosedSessionStatus(session.status)) {
|
||||
appendEvent({ type: "session_status", payload: { status: session.status } });
|
||||
const closedEvent = { type: "session_status", payload: { status: session.status } };
|
||||
applyAutomationEvent(closedEvent);
|
||||
appendEvent(closedEvent);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -159,6 +256,7 @@ async function handleRoute() {
|
||||
// Default: /code → dashboard
|
||||
currentSessionId = null;
|
||||
currentSessionStatus = null;
|
||||
resetAutomationIndicator();
|
||||
showPage("dashboard");
|
||||
disconnectSSE();
|
||||
renderDashboard();
|
||||
@@ -233,6 +331,8 @@ function stopDashboardRefresh() {
|
||||
|
||||
async function renderSessionDetail(id) {
|
||||
currentSessionId = id;
|
||||
resetAutomationIndicator();
|
||||
let session = null;
|
||||
|
||||
// Reset task state for new session and init panel
|
||||
resetTaskState();
|
||||
@@ -240,7 +340,7 @@ async function renderSessionDetail(id) {
|
||||
if (taskPanelEl) initTaskPanel(taskPanelEl);
|
||||
|
||||
try {
|
||||
const session = await apiFetchSession(id);
|
||||
session = await apiFetchSession(id);
|
||||
document.getElementById("session-title").textContent = session.title || session.id;
|
||||
document.getElementById("session-id").textContent = session.id;
|
||||
document.getElementById("session-env").textContent = session.environment_id || "";
|
||||
@@ -254,6 +354,7 @@ async function renderSessionDetail(id) {
|
||||
document.getElementById("event-stream").innerHTML = "";
|
||||
document.getElementById("permission-area").innerHTML = "";
|
||||
document.getElementById("permission-area").classList.add("hidden");
|
||||
applyAutomationSnapshot(session?.automation_state);
|
||||
|
||||
// Load historical events before connecting to live stream
|
||||
resetReplayState();
|
||||
@@ -262,6 +363,7 @@ async function renderSessionDetail(id) {
|
||||
const { events } = await apiFetchSessionHistory(id);
|
||||
if (events && events.length > 0) {
|
||||
for (const event of events) {
|
||||
applyAutomationEvent(event, { replay: true });
|
||||
appendEvent(event, { replay: true });
|
||||
if (event.seqNum && event.seqNum > lastSeqNum) lastSeqNum = event.seqNum;
|
||||
}
|
||||
@@ -273,7 +375,9 @@ async function renderSessionDetail(id) {
|
||||
renderReplayPendingRequests();
|
||||
|
||||
if (isClosedSessionStatus(currentSessionStatus)) {
|
||||
appendEvent({ type: "session_status", payload: { status: currentSessionStatus } });
|
||||
const closedEvent = { type: "session_status", payload: { status: currentSessionStatus } };
|
||||
applyAutomationEvent(closedEvent);
|
||||
appendEvent(closedEvent);
|
||||
disconnectSSE();
|
||||
return;
|
||||
}
|
||||
@@ -291,17 +395,20 @@ function setupControlBar() {
|
||||
const iconSend = document.getElementById("action-icon-send");
|
||||
const iconStop = document.getElementById("action-icon-stop");
|
||||
|
||||
function setBtnState(loading) {
|
||||
actionBtn.classList.toggle("loading", loading);
|
||||
actionBtn.setAttribute("aria-label", loading ? "Stop" : "Send");
|
||||
iconSend.classList.toggle("hidden", loading);
|
||||
iconStop.classList.toggle("hidden", !loading);
|
||||
function setBtnState(mode) {
|
||||
const working = mode === "working";
|
||||
actionBtn.classList.toggle("loading", working);
|
||||
actionBtn.dataset.mode = mode || "idle";
|
||||
actionBtn.setAttribute("aria-label", working ? "Stop" : "Send");
|
||||
iconSend.classList.toggle("hidden", working);
|
||||
iconStop.classList.toggle("hidden", !working);
|
||||
}
|
||||
|
||||
window.__updateActionBtn = setBtnState;
|
||||
setBtnState(getActivityMode());
|
||||
|
||||
actionBtn.addEventListener("click", () => {
|
||||
if (isLoading()) {
|
||||
if (getActivityMode() === "working") {
|
||||
doInterrupt();
|
||||
} else {
|
||||
sendMessage();
|
||||
@@ -319,7 +426,6 @@ async function doInterrupt() {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await apiInterrupt(currentSessionId);
|
||||
appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } });
|
||||
} catch (err) {
|
||||
await syncClosedSessionState(err, "Interrupt failed");
|
||||
} finally {
|
||||
@@ -460,11 +566,28 @@ window._submitAnswers = async function (requestId, btn) {
|
||||
|
||||
function removePermissionPrompt(btn) {
|
||||
const prompt = btn.closest(".permission-prompt, .ask-panel, .plan-panel");
|
||||
const requestId = prompt?.dataset?.requestId || null;
|
||||
if (prompt) prompt.remove();
|
||||
if (requestId) {
|
||||
const stream = document.getElementById("event-stream");
|
||||
stream?.querySelectorAll("[data-pending-request-id]").forEach((row) => {
|
||||
if (row.dataset.pendingRequestId === requestId) row.remove();
|
||||
});
|
||||
}
|
||||
const area = document.getElementById("permission-area");
|
||||
if (area && area.children.length === 0) area.classList.add("hidden");
|
||||
}
|
||||
|
||||
function appendLocalSystemMessage(text) {
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg-row system";
|
||||
row.innerHTML = `<div class="msg-bubble">${esc(text)}</div>`;
|
||||
stream.appendChild(row);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ExitPlanMode interactions
|
||||
// ============================================================
|
||||
@@ -509,6 +632,7 @@ window._submitPlanResponse = async function (requestId, btn) {
|
||||
...(feedback ? { message: feedback } : {}),
|
||||
});
|
||||
removePermissionPrompt(btn);
|
||||
appendLocalSystemMessage("Feedback sent. Continuing in plan mode.");
|
||||
} else {
|
||||
// Approval with permission mode
|
||||
const modeMap = {
|
||||
|
||||
380
packages/remote-control-server/web/automation.js
Normal file
380
packages/remote-control-server/web/automation.js
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Remote Control — Automation helpers
|
||||
*
|
||||
* Centralizes detection of non-human inputs so the web UI can hide
|
||||
* internal prompts while still surfacing session state.
|
||||
*/
|
||||
|
||||
export const PROACTIVE_ENABLED_TEXT =
|
||||
"Proactive mode enabled — model will work autonomously between ticks";
|
||||
export const PROACTIVE_DISABLED_TEXT = "Proactive mode disabled";
|
||||
|
||||
const CLOSED_SESSION_STATUSES = new Set(["archived", "inactive"]);
|
||||
|
||||
const HIDDEN_AUTOMATION_TAGS = new Set([
|
||||
"bash-input",
|
||||
"bash-stderr",
|
||||
"bash-stdout",
|
||||
"channel",
|
||||
"channel-message",
|
||||
"command-args",
|
||||
"command-message",
|
||||
"command-name",
|
||||
"cross-session-message",
|
||||
"fork-boilerplate",
|
||||
"local-command-caveat",
|
||||
"local-command-stderr",
|
||||
"local-command-stdout",
|
||||
"output-file",
|
||||
"reason",
|
||||
"remote-review",
|
||||
"remote-review-progress",
|
||||
"status",
|
||||
"summary",
|
||||
"system-reminder",
|
||||
"task-id",
|
||||
"task-notification",
|
||||
"task-type",
|
||||
"teammate-message",
|
||||
"tick",
|
||||
"tool-use-id",
|
||||
"ultraplan",
|
||||
"worktree",
|
||||
"worktreeBranch",
|
||||
"worktreePath",
|
||||
]);
|
||||
|
||||
const PRIMARY_AUTOMATION_TAGS = new Set([
|
||||
"bash-input",
|
||||
"bash-stderr",
|
||||
"bash-stdout",
|
||||
"channel-message",
|
||||
"command-args",
|
||||
"command-message",
|
||||
"command-name",
|
||||
"cross-session-message",
|
||||
"fork-boilerplate",
|
||||
"local-command-caveat",
|
||||
"local-command-stderr",
|
||||
"local-command-stdout",
|
||||
"remote-review",
|
||||
"remote-review-progress",
|
||||
"system-reminder",
|
||||
"task-notification",
|
||||
"teammate-message",
|
||||
"tick",
|
||||
"ultraplan",
|
||||
]);
|
||||
|
||||
const WORKING_AUTOMATION_TAGS = new Set(
|
||||
[...PRIMARY_AUTOMATION_TAGS].filter(
|
||||
(tag) => tag !== "local-command-caveat" && tag !== "system-reminder",
|
||||
),
|
||||
);
|
||||
|
||||
const XML_ONLY_BLOCK_PATTERN =
|
||||
/^(?:\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*)+$/;
|
||||
const XML_BLOCK_PATTERN =
|
||||
/\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gy;
|
||||
|
||||
function normalizeAutomationStatePayload(payload) {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return {
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: payload.enabled === true,
|
||||
phase: payload.phase === "standby" || payload.phase === "sleeping" ? payload.phase : null,
|
||||
next_tick_at: typeof payload.next_tick_at === "number" ? payload.next_tick_at : null,
|
||||
sleep_until: typeof payload.sleep_until === "number" ? payload.sleep_until : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractEventText(payload) {
|
||||
if (!payload) return "";
|
||||
|
||||
if (typeof payload.content === "string" && payload.content) return payload.content;
|
||||
|
||||
const msg = payload.message;
|
||||
if (msg && typeof msg === "object") {
|
||||
const mc = msg.content;
|
||||
if (typeof mc === "string") return mc;
|
||||
if (Array.isArray(mc)) {
|
||||
return mc
|
||||
.filter((block) => block && typeof block === "object" && block.type === "text")
|
||||
.map((block) => block.text || "")
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
return typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function getOpeningTagNames(text) {
|
||||
const trimmed = String(text).trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
XML_BLOCK_PATTERN.lastIndex = 0;
|
||||
const tags = [];
|
||||
while (XML_BLOCK_PATTERN.lastIndex < trimmed.length) {
|
||||
const match = XML_BLOCK_PATTERN.exec(trimmed);
|
||||
if (!match) return [];
|
||||
tags.push(match[1]);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function isAutomationEnvelopeText(text) {
|
||||
const trimmed = typeof text === "string" ? text.trim() : "";
|
||||
if (!trimmed) return false;
|
||||
if (!XML_ONLY_BLOCK_PATTERN.test(trimmed)) return false;
|
||||
|
||||
const tagNames = getOpeningTagNames(trimmed);
|
||||
return (
|
||||
tagNames.length > 0 &&
|
||||
tagNames.every((tagName) => HIDDEN_AUTOMATION_TAGS.has(tagName)) &&
|
||||
tagNames.some((tagName) => PRIMARY_AUTOMATION_TAGS.has(tagName))
|
||||
);
|
||||
}
|
||||
|
||||
export function isHiddenAutomationUserPayload(payload) {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
if (payload.isSynthetic === true) return true;
|
||||
return isAutomationEnvelopeText(extractEventText(payload));
|
||||
}
|
||||
|
||||
export function shouldHideAutomationUserEvent(payload, direction = "inbound") {
|
||||
return direction === "inbound" && isHiddenAutomationUserPayload(payload);
|
||||
}
|
||||
|
||||
export function shouldStartAutomationWorkFromUserEvent(payload, direction = "inbound") {
|
||||
if (!shouldHideAutomationUserEvent(payload, direction)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = extractEventText(payload).trim();
|
||||
if (!text || !XML_ONLY_BLOCK_PATTERN.test(text)) {
|
||||
return payload?.isSynthetic === true;
|
||||
}
|
||||
|
||||
const tagNames = getOpeningTagNames(text);
|
||||
return tagNames.some((tagName) => WORKING_AUTOMATION_TAGS.has(tagName));
|
||||
}
|
||||
|
||||
export function createAutomationState() {
|
||||
return {
|
||||
proactive: false,
|
||||
autoRun: false,
|
||||
hasAuthority: false,
|
||||
enabled: false,
|
||||
phase: null,
|
||||
nextTickAt: null,
|
||||
sleepUntil: null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAuthoritativeAutomationState(state, payload) {
|
||||
const normalized = normalizeAutomationStatePayload(payload);
|
||||
state.hasAuthority = true;
|
||||
state.enabled = normalized.enabled;
|
||||
state.phase = normalized.phase;
|
||||
state.nextTickAt = normalized.next_tick_at;
|
||||
state.sleepUntil = normalized.sleep_until;
|
||||
state.proactive = normalized.enabled;
|
||||
state.autoRun = false;
|
||||
return state;
|
||||
}
|
||||
|
||||
export function reduceAutomationState(state, event) {
|
||||
const next = state ? { ...state } : createAutomationState();
|
||||
if (!event || typeof event !== "object") return next;
|
||||
|
||||
const type = event.type || "unknown";
|
||||
const payload = event.payload || {};
|
||||
const direction = event.direction || "inbound";
|
||||
|
||||
if (type === "automation_state") {
|
||||
return applyAuthoritativeAutomationState(next, payload);
|
||||
}
|
||||
|
||||
if (type === "session_status") {
|
||||
if (CLOSED_SESSION_STATUSES.has(payload.status)) {
|
||||
if (next.hasAuthority) {
|
||||
return applyAuthoritativeAutomationState(next, null);
|
||||
}
|
||||
next.proactive = false;
|
||||
next.autoRun = false;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
if (next.hasAuthority) {
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const text = extractEventText(payload).trim();
|
||||
if (text === PROACTIVE_ENABLED_TEXT) {
|
||||
next.proactive = true;
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
if (text === PROACTIVE_DISABLED_TEXT) {
|
||||
next.proactive = false;
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === "result" || type === "result_success" || type === "error" || type === "interrupt") {
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === "user" && shouldHideAutomationUserEvent(payload, direction)) {
|
||||
next.autoRun = true;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function shouldPulseAutomationIndicator(event) {
|
||||
if (!event || typeof event !== "object") return false;
|
||||
|
||||
if (event.type === "automation_state") {
|
||||
return event.payload?.enabled === true;
|
||||
}
|
||||
|
||||
if (event.type === "assistant") {
|
||||
const text = extractEventText(event.payload || {}).trim();
|
||||
return text === PROACTIVE_ENABLED_TEXT;
|
||||
}
|
||||
|
||||
return event.type === "user" && shouldHideAutomationUserEvent(event.payload || {}, event.direction || "inbound");
|
||||
}
|
||||
|
||||
export function getAutomationIndicator(state) {
|
||||
if (state?.hasAuthority) {
|
||||
if (!state.enabled) {
|
||||
return {
|
||||
visible: false,
|
||||
label: "",
|
||||
tone: "",
|
||||
title: "",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === "sleeping") {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "sleeping",
|
||||
title: "Claude Code is in proactive mode and currently sleeping until the next wake-up or user message.",
|
||||
iconVariant: "sleeping",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === "standby") {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and waiting for the next scheduled check-in.",
|
||||
iconVariant: "standby",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and may continue working between user messages.",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
if (state?.proactive) {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and may continue working between user messages.",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
if (state?.autoRun) {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Auto Run",
|
||||
tone: "auto-run",
|
||||
title: "Claude Code is processing an automatic background trigger.",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
visible: false,
|
||||
label: "",
|
||||
tone: "",
|
||||
title: "",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
export function getAutomationActivity(state) {
|
||||
if (!state?.hasAuthority || !state.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.phase === "standby") {
|
||||
return {
|
||||
mode: "standby",
|
||||
label: "standby",
|
||||
endsAt: state.nextTickAt,
|
||||
iconVariant: "standby",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === "sleeping") {
|
||||
return {
|
||||
mode: "sleeping",
|
||||
label: "sleeping",
|
||||
endsAt: state.sleepUntil,
|
||||
iconVariant: "sleeping",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function renderAutomationIcon(variant = "active", { className = "", decorative = true } = {}) {
|
||||
const classes = ["clawd-icon", `clawd-icon-${variant}`, className].filter(Boolean).join(" ");
|
||||
const ariaAttrs = decorative ? 'aria-hidden="true"' : 'role="img" aria-label="Claude Code status"';
|
||||
|
||||
return `
|
||||
<span class="${classes}" ${ariaAttrs}>
|
||||
<svg viewBox="0 0 40 30" fill="none">
|
||||
<path class="clawd-arm clawd-arm-left" d="M8.5 13.4C6.6 12.8 5.4 11.4 4.8 9.4C4.6 8.6 4.9 7.7 5.6 7.3C6.3 6.9 7.2 7 7.8 7.6L10.8 10.6L8.5 13.4Z" />
|
||||
<path class="clawd-arm clawd-arm-right" d="M31.5 13.4C33.4 12.8 34.6 11.4 35.2 9.4C35.4 8.6 35.1 7.7 34.4 7.3C33.7 6.9 32.8 7 32.2 7.6L29.2 10.6L31.5 13.4Z" />
|
||||
<path class="clawd-shell" d="M10 12.2C10 7.9 13.5 4.4 17.8 4.4H22.2C26.5 4.4 30 7.9 30 12.2V17.3C30 21 27 24 23.3 24H16.7C13 24 10 21 10 17.3V12.2Z" />
|
||||
<circle class="clawd-eye clawd-eye-left" cx="17.2" cy="13.4" r="1.55" />
|
||||
<circle class="clawd-eye clawd-eye-right" cx="22.8" cy="13.4" r="1.55" />
|
||||
<path class="clawd-eye-line clawd-eye-line-left" d="M15.9 13.6C16.3 12.8 17 12.4 17.9 12.4" />
|
||||
<path class="clawd-eye-line clawd-eye-line-right" d="M22.1 12.4C23 12.4 23.7 12.8 24.1 13.6" />
|
||||
<path class="clawd-foot clawd-foot-left" d="M14.3 25.1C14.3 24 15.2 23.1 16.3 23.1C17.4 23.1 18.3 24 18.3 25.1V25.8H14.3V25.1Z" />
|
||||
<path class="clawd-foot clawd-foot-right" d="M21.7 25.1C21.7 24 22.6 23.1 23.7 23.1C24.8 23.1 25.7 24 25.7 25.1V25.8H21.7V25.1Z" />
|
||||
</svg>
|
||||
<span class="clawd-z clawd-z-1">Z</span>
|
||||
<span class="clawd-z clawd-z-2">Z</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
207
packages/remote-control-server/web/automation.test.js
Normal file
207
packages/remote-control-server/web/automation.test.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
PROACTIVE_DISABLED_TEXT,
|
||||
PROACTIVE_ENABLED_TEXT,
|
||||
createAutomationState,
|
||||
getAutomationActivity,
|
||||
getAutomationIndicator,
|
||||
isAutomationEnvelopeText,
|
||||
reduceAutomationState,
|
||||
shouldHideAutomationUserEvent,
|
||||
shouldStartAutomationWorkFromUserEvent,
|
||||
} from "./automation.js";
|
||||
|
||||
describe("automation helpers", () => {
|
||||
test("keeps real user text visible", () => {
|
||||
expect(shouldHideAutomationUserEvent({ content: "hello from a human" }, "inbound")).toBe(false);
|
||||
});
|
||||
|
||||
test("hides internal xml wrappers without synthetic metadata", () => {
|
||||
expect(isAutomationEnvelopeText("<tick>2:56:47 PM</tick>")).toBe(true);
|
||||
expect(isAutomationEnvelopeText("<system-reminder>\nDo useful work.\n</system-reminder>")).toBe(true);
|
||||
expect(
|
||||
isAutomationEnvelopeText(
|
||||
"<task-notification><summary>Finished</summary><output-file>/tmp/out.log</output-file></task-notification>",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldHideAutomationUserEvent(
|
||||
{ content: "<local-command-caveat>Generated while running local commands.</local-command-caveat>" },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("does not treat slash-command scaffolding as active work", () => {
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{ content: "<local-command-caveat>Generated while running local commands.</local-command-caveat>" },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{
|
||||
content:
|
||||
"<system-reminder>\nProactive mode is now enabled. You will receive periodic <tick> prompts.\n</system-reminder>",
|
||||
isSynthetic: true,
|
||||
},
|
||||
"inbound",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps true automatic triggers eligible for loading state", () => {
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{ content: "<tick>2:56:47 PM</tick>", isSynthetic: true },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{ content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("hides synthetic automatic prompts even when they are plain text", () => {
|
||||
expect(
|
||||
shouldHideAutomationUserEvent(
|
||||
{ content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps mixed human text with tags visible", () => {
|
||||
expect(
|
||||
shouldHideAutomationUserEvent(
|
||||
{ content: "Please keep this: <system-reminder>not metadata</system-reminder>" },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("shows autopilot while proactive mode remains active", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: PROACTIVE_ENABLED_TEXT },
|
||||
});
|
||||
expect(getAutomationIndicator(state)).toEqual({
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and may continue working between user messages.",
|
||||
iconVariant: "active",
|
||||
});
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "user",
|
||||
direction: "inbound",
|
||||
payload: { content: "<tick>3:15:00 PM</tick>" },
|
||||
});
|
||||
expect(getAutomationIndicator(state).label).toBe("Autopilot");
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: "Working on background maintenance." },
|
||||
});
|
||||
expect(getAutomationIndicator(state).label).toBe("Autopilot");
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: PROACTIVE_DISABLED_TEXT },
|
||||
});
|
||||
expect(getAutomationIndicator(state).visible).toBe(false);
|
||||
});
|
||||
|
||||
test("shows auto run until an automatic trigger settles", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "user",
|
||||
direction: "inbound",
|
||||
payload: { content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
});
|
||||
expect(getAutomationIndicator(state).label).toBe("Auto Run");
|
||||
expect(getAutomationIndicator(state).iconVariant).toBe("active");
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: "Completed scheduled refresh." },
|
||||
});
|
||||
expect(getAutomationIndicator(state).visible).toBe(false);
|
||||
});
|
||||
|
||||
test("authoritative automation_state drives standby and sleeping states", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "automation_state",
|
||||
payload: {
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 123456,
|
||||
sleep_until: null,
|
||||
},
|
||||
});
|
||||
expect(getAutomationIndicator(state)).toEqual({
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and waiting for the next scheduled check-in.",
|
||||
iconVariant: "standby",
|
||||
});
|
||||
expect(getAutomationActivity(state)).toEqual({
|
||||
mode: "standby",
|
||||
label: "standby",
|
||||
endsAt: 123456,
|
||||
iconVariant: "standby",
|
||||
});
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "automation_state",
|
||||
payload: {
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 999999,
|
||||
},
|
||||
});
|
||||
expect(getAutomationIndicator(state).tone).toBe("sleeping");
|
||||
expect(getAutomationIndicator(state).iconVariant).toBe("sleeping");
|
||||
expect(getAutomationActivity(state)).toEqual({
|
||||
mode: "sleeping",
|
||||
label: "sleeping",
|
||||
endsAt: 999999,
|
||||
iconVariant: "sleeping",
|
||||
});
|
||||
});
|
||||
|
||||
test("authoritative disabled snapshot suppresses heuristic auto-run fallback", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "automation_state",
|
||||
payload: {
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
},
|
||||
});
|
||||
state = reduceAutomationState(state, {
|
||||
type: "user",
|
||||
direction: "inbound",
|
||||
payload: { content: "<tick>3:15:00 PM</tick>" },
|
||||
});
|
||||
|
||||
expect(getAutomationIndicator(state).visible).toBe(false);
|
||||
expect(getAutomationActivity(state)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,7 @@
|
||||
<div class="session-meta-row">
|
||||
<span id="session-id" class="meta-item"></span>
|
||||
<span id="session-status" class="status-badge"></span>
|
||||
<span id="session-automation" class="automation-pill hidden" aria-live="polite"></span>
|
||||
<span id="session-env" class="meta-item"></span>
|
||||
<span id="session-time" class="meta-item"></span>
|
||||
<button id="task-panel-toggle" class="nav-link btn-text" title="Tasks & Todos">
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
.msg-row.user { align-self: flex-end; }
|
||||
.msg-row.assistant { align-self: flex-start; }
|
||||
.msg-row.tool { align-self: flex-start; max-width: 95%; }
|
||||
.msg-row.tool-trace-row { align-self: flex-start; max-width: 92%; }
|
||||
.msg-row.system { align-self: center; }
|
||||
.msg-row.result { align-self: center; }
|
||||
|
||||
@@ -51,6 +52,124 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.assistant-turn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.assistant-turn-orphan {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.assistant-trace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.assistant-trace.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.assistant-trace-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(160, 120, 96, 0.16);
|
||||
background: rgba(245, 243, 239, 0.78);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px 6px 8px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
transition: all var(--transition-fast);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.assistant-trace-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(217, 119, 87, 0.28);
|
||||
background: rgba(250, 247, 242, 0.98);
|
||||
}
|
||||
|
||||
.assistant-trace-toggle.has-error {
|
||||
color: var(--red);
|
||||
border-color: rgba(196, 64, 64, 0.24);
|
||||
background: rgba(252, 238, 238, 0.88);
|
||||
}
|
||||
|
||||
.assistant-trace-glyph {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
min-width: 14px;
|
||||
}
|
||||
|
||||
.assistant-trace-glyph span {
|
||||
display: block;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.assistant-trace-glyph span:nth-child(1) { height: 7px; }
|
||||
.assistant-trace-glyph span:nth-child(2) { height: 10px; }
|
||||
.assistant-trace-glyph span:nth-child(3) { height: 5px; }
|
||||
|
||||
.assistant-trace-count {
|
||||
min-width: 1ch;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.assistant-trace-chevron {
|
||||
font-size: 0.9rem;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.assistant-trace-toggle.is-open .assistant-trace-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.assistant-trace-panel {
|
||||
width: min(100%, 720px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(160, 120, 96, 0.14);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(250, 247, 242, 0.98), rgba(245, 243, 239, 0.92)),
|
||||
var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.assistant-trace-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.assistant-trace-card {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.assistant-trace-card:hover {
|
||||
border-color: rgba(217, 119, 87, 0.24);
|
||||
}
|
||||
|
||||
.assistant-trace-card-error {
|
||||
border-color: rgba(196, 64, 64, 0.24);
|
||||
}
|
||||
|
||||
.assistant-trace-card-error:hover {
|
||||
border-color: rgba(196, 64, 64, 0.4);
|
||||
}
|
||||
|
||||
.msg-row.system .msg-bubble {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
@@ -98,6 +217,7 @@
|
||||
font-size: 0.7rem;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.tool-card-header.is-open .tool-icon,
|
||||
.tool-card-header:hover .tool-icon { transform: rotate(90deg); }
|
||||
|
||||
.tool-card-body {
|
||||
@@ -329,15 +449,51 @@
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.plan-panel .plan-content > :first-child { margin-top: 0; }
|
||||
.plan-panel .plan-content > :last-child { margin-bottom: 0; }
|
||||
.plan-panel .plan-content h1,
|
||||
.plan-panel .plan-content h2,
|
||||
.plan-panel .plan-content h3,
|
||||
.plan-panel .plan-content h4,
|
||||
.plan-panel .plan-content h5,
|
||||
.plan-panel .plan-content h6 {
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.3;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
}
|
||||
.plan-panel .plan-content h1 { font-size: 1.15rem; }
|
||||
.plan-panel .plan-content h2 { font-size: 1.05rem; }
|
||||
.plan-panel .plan-content h3,
|
||||
.plan-panel .plan-content h4,
|
||||
.plan-panel .plan-content h5,
|
||||
.plan-panel .plan-content h6 { font-size: 0.95rem; }
|
||||
.plan-panel .plan-content p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.plan-panel .plan-content ul,
|
||||
.plan-panel .plan-content ol {
|
||||
margin: 0 0 12px 1.35em;
|
||||
padding: 0;
|
||||
}
|
||||
.plan-panel .plan-content li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.plan-panel .plan-content pre {
|
||||
background: var(--bg-tool-card);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 6px 0;
|
||||
margin: 10px 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.plan-panel .plan-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
.plan-panel .plan-content code {
|
||||
background: var(--bg-tool-card);
|
||||
padding: 2px 5px;
|
||||
@@ -479,3 +635,58 @@
|
||||
font-family: var(--font-mono);
|
||||
margin-left: auto;
|
||||
}
|
||||
.automation-activity-row {
|
||||
align-self: flex-start;
|
||||
max-width: 92%;
|
||||
}
|
||||
.automation-activity-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(217, 119, 87, 0.16);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(217, 119, 87, 0.08), rgba(250, 247, 242, 0.94)),
|
||||
var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.automation-activity-standby .automation-activity-card {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.automation-activity-sleeping .automation-activity-card {
|
||||
color: var(--green);
|
||||
border-color: rgba(59, 138, 106, 0.16);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(59, 138, 106, 0.08), rgba(250, 247, 242, 0.94)),
|
||||
var(--bg-card);
|
||||
}
|
||||
.automation-activity-icon {
|
||||
width: 34px;
|
||||
height: 26px;
|
||||
}
|
||||
.automation-activity-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.automation-activity-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.automation-activity-countdown {
|
||||
margin-left: auto;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(160, 120, 96, 0.14);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -234,6 +234,164 @@
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.automation-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
|
||||
}
|
||||
.automation-pill-icon { width: 24px; height: 18px; flex-shrink: 0; }
|
||||
.automation-pill-label { line-height: 1; }
|
||||
.automation-pill-proactive {
|
||||
color: var(--accent-hover);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(217, 119, 87, 0.12), rgba(217, 119, 87, 0.06)),
|
||||
var(--bg-card);
|
||||
border-color: rgba(217, 119, 87, 0.18);
|
||||
}
|
||||
.automation-pill-sleeping {
|
||||
color: var(--green);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(59, 138, 106, 0.14), rgba(59, 138, 106, 0.05)),
|
||||
var(--bg-card);
|
||||
border-color: rgba(59, 138, 106, 0.18);
|
||||
}
|
||||
.automation-pill-auto-run {
|
||||
color: var(--green);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(59, 138, 106, 0.12), rgba(59, 138, 106, 0.05)),
|
||||
var(--bg-card);
|
||||
border-color: rgba(59, 138, 106, 0.18);
|
||||
}
|
||||
.automation-pill.is-pulsing {
|
||||
animation: automationPillPulse 1.2s ease-out;
|
||||
}
|
||||
.automation-pill.is-pulsing .clawd-icon {
|
||||
animation: automationDotPulse 1.2s ease-out;
|
||||
}
|
||||
@keyframes automationPillPulse {
|
||||
0% { transform: translateY(0) scale(1); box-shadow: var(--shadow-sm); }
|
||||
35% { transform: translateY(-1px) scale(1.02); box-shadow: var(--shadow-md); }
|
||||
100% { transform: translateY(0) scale(1); box-shadow: var(--shadow-sm); }
|
||||
}
|
||||
@keyframes automationDotPulse {
|
||||
0% { transform: scale(1); opacity: 0.9; }
|
||||
35% { transform: scale(1.5); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 0.92; }
|
||||
}
|
||||
.clawd-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 30px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.clawd-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.clawd-shell,
|
||||
.clawd-foot { fill: currentColor; }
|
||||
.clawd-shell { opacity: 0.9; }
|
||||
.clawd-arm { fill: currentColor; opacity: 0.74; }
|
||||
.clawd-eye {
|
||||
fill: var(--text-primary);
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
.clawd-eye-line {
|
||||
display: none;
|
||||
stroke: var(--text-primary);
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.clawd-z {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -2px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 700;
|
||||
color: currentColor;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.clawd-z-2 {
|
||||
top: -9px;
|
||||
right: 4px;
|
||||
font-size: 0.48rem;
|
||||
}
|
||||
.clawd-icon-standby svg {
|
||||
animation: clawdStandbyBob 2.4s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-standby .clawd-eye-left {
|
||||
animation: clawdLookLeft 2.4s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-standby .clawd-eye-right {
|
||||
animation: clawdLookRight 2.4s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-sleeping svg {
|
||||
animation: clawdSleepFloat 3.2s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-eye {
|
||||
display: none;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-eye-line {
|
||||
display: block;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-z {
|
||||
opacity: 0.88;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-z-1 {
|
||||
animation: clawdSleepZ 2.7s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-z-2 {
|
||||
animation: clawdSleepZ 2.7s ease-in-out infinite 0.45s;
|
||||
}
|
||||
@keyframes clawdStandbyBob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-1px); }
|
||||
}
|
||||
@keyframes clawdLookLeft {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-0.8px); }
|
||||
55% { transform: translateX(0.6px); }
|
||||
}
|
||||
@keyframes clawdLookRight {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-0.6px); }
|
||||
55% { transform: translateX(0.8px); }
|
||||
}
|
||||
@keyframes clawdSleepFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(1px); }
|
||||
}
|
||||
@keyframes clawdSleepZ {
|
||||
0% { transform: translate(0, 0) scale(0.94); opacity: 0; }
|
||||
20% { opacity: 0.88; }
|
||||
100% { transform: translate(4px, -8px) scale(1.04); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.automation-pill.is-pulsing,
|
||||
.automation-pill.is-pulsing .clawd-icon,
|
||||
.clawd-icon-standby svg,
|
||||
.clawd-icon-standby .clawd-eye-left,
|
||||
.clawd-icon-standby .clawd-eye-right,
|
||||
.clawd-icon-sleeping svg,
|
||||
.clawd-icon-sleeping .clawd-z-1,
|
||||
.clawd-icon-sleeping .clawd-z-2 {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.meta-item {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
|
||||
30
packages/remote-control-server/web/render-activity.test.js
Normal file
30
packages/remote-control-server/web/render-activity.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
formatCountdownRemaining,
|
||||
resolveActivityMode,
|
||||
shouldRenderTranscriptActivity,
|
||||
} from "./render.js";
|
||||
|
||||
describe("render activity helpers", () => {
|
||||
test("authoritative standby and sleeping states override stale working spinners", () => {
|
||||
expect(resolveActivityMode(true, { mode: "standby" })).toBe("standby");
|
||||
expect(resolveActivityMode(true, { mode: "sleeping" })).toBe("sleeping");
|
||||
expect(resolveActivityMode(true, null)).toBe("working");
|
||||
expect(resolveActivityMode(false, null)).toBe("idle");
|
||||
});
|
||||
|
||||
test("formats countdowns compactly", () => {
|
||||
expect(formatCountdownRemaining(35_000, 0)).toBe("35s");
|
||||
expect(formatCountdownRemaining(185_000, 0)).toBe("3m 5s");
|
||||
expect(formatCountdownRemaining(3_900_000, 0)).toBe("1h 5m");
|
||||
expect(formatCountdownRemaining(null, 0)).toBe("");
|
||||
});
|
||||
|
||||
test("renders transcript activity only for active work", () => {
|
||||
expect(shouldRenderTranscriptActivity("working")).toBe(true);
|
||||
expect(shouldRenderTranscriptActivity("standby")).toBe(false);
|
||||
expect(shouldRenderTranscriptActivity("sleeping")).toBe(false);
|
||||
expect(shouldRenderTranscriptActivity("idle")).toBe(false);
|
||||
});
|
||||
});
|
||||
36
packages/remote-control-server/web/render-plan.test.js
Normal file
36
packages/remote-control-server/web/render-plan.test.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { formatPlanContent } from "./render.js";
|
||||
|
||||
describe("formatPlanContent", () => {
|
||||
test("renders headings, paragraphs, and lists for plan panels", () => {
|
||||
const html = formatPlanContent(`## Summary
|
||||
Line one
|
||||
Line two
|
||||
|
||||
- First item
|
||||
- Second item
|
||||
|
||||
1. Step one
|
||||
2. Step two`);
|
||||
|
||||
expect(html).toContain("<h2>Summary</h2>");
|
||||
expect(html).toContain("<p>Line one<br>Line two</p>");
|
||||
expect(html).toContain("<ul><li>First item</li><li>Second item</li></ul>");
|
||||
expect(html).toContain("<ol><li>Step one</li><li>Step two</li></ol>");
|
||||
});
|
||||
|
||||
test("escapes unsafe markup and preserves inline formatting plus code blocks", () => {
|
||||
const html = formatPlanContent(`**Bold** with \`inline\` and <script>alert(1)</script>
|
||||
|
||||
\`\`\`js
|
||||
const markup = "<div>";
|
||||
\`\`\``);
|
||||
|
||||
expect(html).toContain("<strong>Bold</strong>");
|
||||
expect(html).toContain("<code");
|
||||
expect(html).toContain("inline</code>");
|
||||
expect(html).toContain("<script>alert(1)</script>");
|
||||
expect(html).toContain("<pre><code>const markup = "<div>";</code></pre>");
|
||||
});
|
||||
});
|
||||
24
packages/remote-control-server/web/render-status.test.js
Normal file
24
packages/remote-control-server/web/render-status.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { isConversationClearedStatus } from "./render.js";
|
||||
|
||||
describe("status helpers", () => {
|
||||
test("detects direct conversation reset markers", () => {
|
||||
expect(isConversationClearedStatus({ status: "conversation_cleared" })).toBe(true);
|
||||
});
|
||||
|
||||
test("detects nested raw conversation reset markers", () => {
|
||||
expect(
|
||||
isConversationClearedStatus({
|
||||
status: "",
|
||||
raw: { status: "conversation_cleared" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores unrelated status payloads", () => {
|
||||
expect(isConversationClearedStatus({ status: "running" })).toBe(false);
|
||||
expect(isConversationClearedStatus({})).toBe(false);
|
||||
expect(isConversationClearedStatus(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
90
packages/remote-control-server/web/render-trace.test.js
Normal file
90
packages/remote-control-server/web/render-trace.test.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
addAssistantToolTraceHost,
|
||||
addToolTraceEntry,
|
||||
clearActiveToolTraceHost,
|
||||
createToolTraceState,
|
||||
} from "./render.js";
|
||||
|
||||
describe("tool trace grouping state", () => {
|
||||
test("keeps tool entries attached to the current assistant turn", () => {
|
||||
let state = createToolTraceState();
|
||||
|
||||
const assistant = addAssistantToolTraceHost(state, "Checking the repo");
|
||||
state = assistant.state;
|
||||
|
||||
const toolUse = addToolTraceEntry(state, "use");
|
||||
state = toolUse.state;
|
||||
|
||||
const toolResult = addToolTraceEntry(state, "result");
|
||||
state = toolResult.state;
|
||||
|
||||
expect(assistant.host).toEqual({
|
||||
id: "trace-1",
|
||||
kind: "assistant",
|
||||
assistantContent: "Checking the repo",
|
||||
entryKinds: [],
|
||||
});
|
||||
expect(toolUse.createdHost).toBeNull();
|
||||
expect(toolResult.createdHost).toBeNull();
|
||||
expect(state.hosts).toEqual([
|
||||
{
|
||||
id: "trace-1",
|
||||
kind: "assistant",
|
||||
assistantContent: "Checking the repo",
|
||||
entryKinds: ["use", "result"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates an orphan trace host when tool activity has no assistant turn", () => {
|
||||
const result = addToolTraceEntry(createToolTraceState(), "use");
|
||||
|
||||
expect(result.createdHost).toEqual({
|
||||
id: "trace-1",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["use"],
|
||||
});
|
||||
expect(result.state.hosts).toEqual([
|
||||
{
|
||||
id: "trace-1",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["use"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("starts a new orphan host after a visible user turn clears the active assistant host", () => {
|
||||
let state = createToolTraceState();
|
||||
state = addAssistantToolTraceHost(state, "Running tools").state;
|
||||
state = addToolTraceEntry(state, "use").state;
|
||||
|
||||
state = clearActiveToolTraceHost(state);
|
||||
|
||||
const nextResult = addToolTraceEntry(state, "result");
|
||||
|
||||
expect(nextResult.createdHost).toEqual({
|
||||
id: "trace-2",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["result"],
|
||||
});
|
||||
expect(nextResult.state.hosts).toEqual([
|
||||
{
|
||||
id: "trace-1",
|
||||
kind: "assistant",
|
||||
assistantContent: "Running tools",
|
||||
entryKinds: ["use"],
|
||||
},
|
||||
{
|
||||
id: "trace-2",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["result"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,13 @@
|
||||
*/
|
||||
|
||||
import { esc } from "./utils.js";
|
||||
import { processAssistantEvent } from "./task-panel.js";
|
||||
import {
|
||||
extractEventText,
|
||||
renderAutomationIcon,
|
||||
shouldHideAutomationUserEvent,
|
||||
shouldStartAutomationWorkFromUserEvent,
|
||||
} from "./automation.js";
|
||||
import { applyTaskStateEvent, processAssistantEvent } from "./task-panel.js";
|
||||
|
||||
// ============================================================
|
||||
// Replay state — tracks unresolved permission requests during history replay
|
||||
@@ -14,12 +20,116 @@ import { processAssistantEvent } from "./task-panel.js";
|
||||
const replayPendingRequests = new Map(); // request_id → event data (unresolved)
|
||||
const replayRespondedRequests = new Set(); // request_ids that have a response
|
||||
const renderedUserUuids = new Set();
|
||||
const traceHostElements = new Map(); // host_id → DOM refs for inline tool traces
|
||||
|
||||
export function createToolTraceState() {
|
||||
return {
|
||||
nextHostId: 1,
|
||||
activeHostId: null,
|
||||
hosts: [],
|
||||
};
|
||||
}
|
||||
|
||||
function cloneToolTraceState(state) {
|
||||
return {
|
||||
nextHostId: state.nextHostId,
|
||||
activeHostId: state.activeHostId,
|
||||
hosts: state.hosts.map((host) => ({
|
||||
...host,
|
||||
entryKinds: [...host.entryKinds],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createToolTraceHost(nextState, kind, assistantContent = "") {
|
||||
const host = {
|
||||
id: `trace-${nextState.nextHostId}`,
|
||||
kind,
|
||||
assistantContent,
|
||||
entryKinds: [],
|
||||
};
|
||||
nextState.nextHostId += 1;
|
||||
nextState.activeHostId = host.id;
|
||||
nextState.hosts.push(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
export function addAssistantToolTraceHost(state, content) {
|
||||
const nextState = cloneToolTraceState(state);
|
||||
const host = createToolTraceHost(nextState, "assistant", content);
|
||||
return { state: nextState, host };
|
||||
}
|
||||
|
||||
export function clearActiveToolTraceHost(state) {
|
||||
if (!state.activeHostId) return state;
|
||||
const nextState = cloneToolTraceState(state);
|
||||
nextState.activeHostId = null;
|
||||
return nextState;
|
||||
}
|
||||
|
||||
export function addToolTraceEntry(state, entryKind) {
|
||||
const nextState = cloneToolTraceState(state);
|
||||
let host = nextState.hosts.find((item) => item.id === nextState.activeHostId);
|
||||
let createdHost = null;
|
||||
|
||||
if (!host) {
|
||||
createdHost = createToolTraceHost(nextState, "orphan");
|
||||
host = createdHost;
|
||||
}
|
||||
|
||||
host.entryKinds.push(entryKind);
|
||||
return { state: nextState, host, createdHost };
|
||||
}
|
||||
|
||||
let toolTraceState = createToolTraceState();
|
||||
|
||||
function resetToolTraceRuntime() {
|
||||
toolTraceState = createToolTraceState();
|
||||
traceHostElements.clear();
|
||||
}
|
||||
|
||||
/** Clear replay tracking state (call before each history load) */
|
||||
export function resetReplayState() {
|
||||
replayPendingRequests.clear();
|
||||
replayRespondedRequests.clear();
|
||||
renderedUserUuids.clear();
|
||||
resetToolTraceRuntime();
|
||||
}
|
||||
|
||||
export function isConversationClearedStatus(payload) {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
if (payload.status === "conversation_cleared") return true;
|
||||
const raw = payload.raw;
|
||||
return !!raw && typeof raw === "object" && raw.status === "conversation_cleared";
|
||||
}
|
||||
|
||||
function clearTranscriptView() {
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
|
||||
let preservedClearCommand = null;
|
||||
for (let i = stream.children.length - 1; i >= 0; i -= 1) {
|
||||
const row = stream.children[i];
|
||||
if (!row || typeof row.textContent !== "string") continue;
|
||||
if (row.textContent.trim() === "/clear") {
|
||||
preservedClearCommand = row.cloneNode(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
stream.innerHTML = "";
|
||||
if (preservedClearCommand) {
|
||||
stream.appendChild(preservedClearCommand);
|
||||
}
|
||||
|
||||
const permissionArea = document.getElementById("permission-area");
|
||||
if (permissionArea) {
|
||||
permissionArea.innerHTML = "";
|
||||
permissionArea.classList.add("hidden");
|
||||
}
|
||||
|
||||
removeLoading();
|
||||
resetReplayState();
|
||||
}
|
||||
|
||||
/** After replay finishes, render any still-unresolved permission prompts */
|
||||
@@ -50,27 +160,15 @@ function truncate(str, max) {
|
||||
* Server-side normalization guarantees payload.content is a string.
|
||||
* Falls back to raw/message parsing for backward compat.
|
||||
*/
|
||||
export function extractText(payload) {
|
||||
if (!payload) return "";
|
||||
export const extractText = extractEventText;
|
||||
|
||||
// Normalized format (server standardized)
|
||||
if (typeof payload.content === "string" && payload.content) return payload.content;
|
||||
|
||||
// Fallback: raw message.content (child process format)
|
||||
const msg = payload.message;
|
||||
if (msg && typeof msg === "object") {
|
||||
const mc = msg.content;
|
||||
if (typeof mc === "string") return mc;
|
||||
if (Array.isArray(mc)) {
|
||||
return mc
|
||||
.filter((b) => b && typeof b === "object" && b.type === "text")
|
||||
.map((b) => b.text || "")
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
function formatInlineContent(content) {
|
||||
let html = esc(content);
|
||||
// Inline code: `...`
|
||||
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tool-card);padding:2px 5px;border-radius:3px;font-family:var(--font-mono);font-size:0.85em;">$1</code>');
|
||||
// Bold: **...**
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
return html;
|
||||
}
|
||||
|
||||
function formatAssistantContent(content) {
|
||||
@@ -79,13 +177,106 @@ function formatAssistantContent(content) {
|
||||
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||
return `<pre style="background:var(--bg-tool-card);padding:10px;border-radius:6px;overflow-x:auto;margin:6px 0;font-family:var(--font-mono);font-size:0.82rem;">${code.trim()}</pre>`;
|
||||
});
|
||||
// Inline code: `...`
|
||||
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tool-card);padding:2px 5px;border-radius:3px;font-family:var(--font-mono);font-size:0.85em;">$1</code>');
|
||||
// Bold: **...**
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
html = formatInlineContent(html);
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderPlanCodeBlock(code) {
|
||||
return `<pre><code>${esc(code.trim())}</code></pre>`;
|
||||
}
|
||||
|
||||
function formatPlanTextBlock(content) {
|
||||
const blocks = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
let paragraph = [];
|
||||
let listType = null;
|
||||
let listItems = [];
|
||||
|
||||
function flushParagraph() {
|
||||
if (paragraph.length === 0) return;
|
||||
blocks.push(`<p>${paragraph.map(line => formatInlineContent(line)).join("<br>")}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
|
||||
function flushList() {
|
||||
if (!listType || listItems.length === 0) return;
|
||||
blocks.push(`<${listType}>${listItems.map(item => `<li>${item}</li>`).join("")}</${listType}>`);
|
||||
listType = null;
|
||||
listItems = [];
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (headingMatch) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
const level = Math.min(headingMatch[1].length, 6);
|
||||
blocks.push(`<h${level}>${formatInlineContent(headingMatch[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedMatch = trimmed.match(/^[-*]\s+(.*)$/);
|
||||
if (unorderedMatch) {
|
||||
flushParagraph();
|
||||
if (listType !== "ul") {
|
||||
flushList();
|
||||
listType = "ul";
|
||||
}
|
||||
listItems.push(formatInlineContent(unorderedMatch[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/);
|
||||
if (orderedMatch) {
|
||||
flushParagraph();
|
||||
if (listType !== "ol") {
|
||||
flushList();
|
||||
listType = "ol";
|
||||
}
|
||||
listItems.push(formatInlineContent(orderedMatch[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList();
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
flushList();
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
export function formatPlanContent(content) {
|
||||
const parts = [];
|
||||
const codeBlockPattern = /```(\w*)\n?([\s\S]*?)```/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = codeBlockPattern.exec(content)) !== null) {
|
||||
const precedingText = content.slice(lastIndex, match.index);
|
||||
if (precedingText.trim()) {
|
||||
parts.push(formatPlanTextBlock(precedingText));
|
||||
}
|
||||
parts.push(renderPlanCodeBlock(match[2]));
|
||||
lastIndex = codeBlockPattern.lastIndex;
|
||||
}
|
||||
|
||||
const trailingText = content.slice(lastIndex);
|
||||
if (trailingText.trim()) {
|
||||
parts.push(formatPlanTextBlock(trailingText));
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function getUserUuid(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
if (typeof payload.uuid === "string" && payload.uuid) return payload.uuid;
|
||||
@@ -95,7 +286,7 @@ function getUserUuid(payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldRenderUserEvent(payload, direction, replay) {
|
||||
function shouldProcessUserEvent(payload, direction) {
|
||||
const uuid = getUserUuid(payload);
|
||||
if (uuid) {
|
||||
if (renderedUserUuids.has(uuid)) return false;
|
||||
@@ -103,10 +294,10 @@ function shouldRenderUserEvent(payload, direction, replay) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy fallback with no uuid: keep the previous no-duplicate behavior.
|
||||
// Live inbound user events without a uuid are most likely echoes of a web-
|
||||
// sent message; replay keeps the prior "outbound only" rule as well.
|
||||
return direction === "outbound";
|
||||
// Legacy fallback with no uuid: inbound human messages are usually echoes
|
||||
// of a web-sent prompt, but hidden automation inputs still need to drive
|
||||
// loading state and the session status marker.
|
||||
return direction === "outbound" || shouldHideAutomationUserEvent(payload, direction);
|
||||
}
|
||||
|
||||
function getMessageContentBlocks(payload) {
|
||||
@@ -116,27 +307,8 @@ function getMessageContentBlocks(payload) {
|
||||
return msg.content.filter((block) => block && typeof block === "object");
|
||||
}
|
||||
|
||||
function renderEmbeddedToolUseBlocks(payload) {
|
||||
return getMessageContentBlocks(payload)
|
||||
.filter((block) => block.type === "tool_use")
|
||||
.map((block) =>
|
||||
renderToolUse({
|
||||
tool_name: block.name || "tool",
|
||||
tool_input: block.input || {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function renderEmbeddedToolResultBlocks(payload) {
|
||||
return getMessageContentBlocks(payload)
|
||||
.filter((block) => block.type === "tool_result")
|
||||
.map((block) =>
|
||||
renderToolResult({
|
||||
content: block.content || "",
|
||||
output: block.content || "",
|
||||
is_error: !!block.is_error,
|
||||
}),
|
||||
);
|
||||
function getEmbeddedToolBlocks(payload, blockType) {
|
||||
return getMessageContentBlocks(payload).filter((block) => block.type === blockType);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -162,30 +334,63 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
switch (type) {
|
||||
case "user":
|
||||
{
|
||||
const toolResultEls = renderEmbeddedToolResultBlocks(payload);
|
||||
if (toolResultEls.length > 0) {
|
||||
histEls.push(...toolResultEls);
|
||||
const toolResultBlocks = getEmbeddedToolBlocks(payload, "tool_result");
|
||||
if (toolResultBlocks.length > 0) {
|
||||
for (const block of toolResultBlocks) {
|
||||
appendToolEntryToActiveTrace(
|
||||
"result",
|
||||
{
|
||||
content: block.content || "",
|
||||
output: block.content || "",
|
||||
is_error: !!block.is_error,
|
||||
},
|
||||
histEls,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (shouldRenderUserEvent(payload, direction, true)) {
|
||||
if (shouldProcessUserEvent(payload, direction)) {
|
||||
if (shouldHideAutomationUserEvent(payload, direction)) {
|
||||
break;
|
||||
}
|
||||
toolTraceState = clearActiveToolTraceHost(toolTraceState);
|
||||
histEls.push(renderUserMessage(payload, direction));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "assistant":
|
||||
{
|
||||
const toolUseEls = renderEmbeddedToolUseBlocks(payload);
|
||||
const text = extractText(payload);
|
||||
const toolUseBlocks = getEmbeddedToolBlocks(payload, "tool_use");
|
||||
if (text && text.trim()) histEls.push(renderAssistantMessage(payload));
|
||||
if (toolUseEls.length > 0) histEls.push(...toolUseEls);
|
||||
for (const block of toolUseBlocks) {
|
||||
appendToolEntryToActiveTrace(
|
||||
"use",
|
||||
{
|
||||
tool_name: block.name || "tool",
|
||||
tool_input: block.input || {},
|
||||
},
|
||||
histEls,
|
||||
);
|
||||
}
|
||||
processAssistantEvent(payload);
|
||||
}
|
||||
break;
|
||||
case "task_state":
|
||||
applyTaskStateEvent(payload);
|
||||
return;
|
||||
case "automation_state":
|
||||
return;
|
||||
case "status":
|
||||
if (isConversationClearedStatus(payload)) {
|
||||
clearTranscriptView();
|
||||
}
|
||||
return;
|
||||
case "tool_use":
|
||||
histEls.push(renderToolUse(payload));
|
||||
appendToolEntryToActiveTrace("use", payload, histEls);
|
||||
break;
|
||||
case "tool_result":
|
||||
histEls.push(renderToolResult(payload));
|
||||
appendToolEntryToActiveTrace("result", payload, histEls);
|
||||
break;
|
||||
case "error":
|
||||
histEls.push(renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`));
|
||||
@@ -230,17 +435,32 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
const els = [];
|
||||
let needLoading = false;
|
||||
|
||||
switch (type) {
|
||||
switch (type) {
|
||||
case "user":
|
||||
{
|
||||
const toolResultEls = renderEmbeddedToolResultBlocks(payload);
|
||||
if (toolResultEls.length > 0) {
|
||||
els.push(...toolResultEls);
|
||||
const toolResultBlocks = getEmbeddedToolBlocks(payload, "tool_result");
|
||||
if (toolResultBlocks.length > 0) {
|
||||
for (const block of toolResultBlocks) {
|
||||
appendToolEntryToActiveTrace(
|
||||
"result",
|
||||
{
|
||||
content: block.content || "",
|
||||
output: block.content || "",
|
||||
is_error: !!block.is_error,
|
||||
},
|
||||
els,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!shouldRenderUserEvent(payload, direction, false)) return;
|
||||
els.push(renderUserMessage(payload, direction));
|
||||
needLoading = true;
|
||||
if (!shouldProcessUserEvent(payload, direction)) return;
|
||||
if (!shouldHideAutomationUserEvent(payload, direction)) {
|
||||
toolTraceState = clearActiveToolTraceHost(toolTraceState);
|
||||
els.push(renderUserMessage(payload, direction));
|
||||
needLoading = true;
|
||||
} else {
|
||||
needLoading = shouldStartAutomationWorkFromUserEvent(payload, direction);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "partial_assistant":
|
||||
@@ -249,26 +469,40 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
return;
|
||||
case "assistant":
|
||||
{
|
||||
const toolUseEls = renderEmbeddedToolUseBlocks(payload);
|
||||
const text = extractText(payload);
|
||||
const toolUseBlocks = getEmbeddedToolBlocks(payload, "tool_use");
|
||||
if (text && text.trim()) {
|
||||
removeLoading();
|
||||
els.push(renderAssistantMessage(payload));
|
||||
}
|
||||
if (toolUseEls.length > 0) els.push(...toolUseEls);
|
||||
for (const block of toolUseBlocks) {
|
||||
appendToolEntryToActiveTrace(
|
||||
"use",
|
||||
{
|
||||
tool_name: block.name || "tool",
|
||||
tool_input: block.input || {},
|
||||
},
|
||||
els,
|
||||
);
|
||||
}
|
||||
processAssistantEvent(payload);
|
||||
}
|
||||
break;
|
||||
case "task_state":
|
||||
applyTaskStateEvent(payload);
|
||||
return;
|
||||
case "automation_state":
|
||||
return;
|
||||
case "result":
|
||||
case "result_success":
|
||||
removeLoading();
|
||||
// Skip result — it just repeats the assistant message content
|
||||
return;
|
||||
case "tool_use":
|
||||
els.push(renderToolUse(payload));
|
||||
appendToolEntryToActiveTrace("use", payload, els);
|
||||
break;
|
||||
case "tool_result":
|
||||
els.push(renderToolResult(payload));
|
||||
appendToolEntryToActiveTrace("result", payload, els);
|
||||
break;
|
||||
case "control_request":
|
||||
case "permission_request":
|
||||
@@ -305,6 +539,10 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
return;
|
||||
case "status":
|
||||
// Skip connecting/waiting status noise from bridge
|
||||
if (isConversationClearedStatus(payload)) {
|
||||
clearTranscriptView();
|
||||
return;
|
||||
}
|
||||
{
|
||||
const msg = payload.message || payload.content || "";
|
||||
const fullText = typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
@@ -359,14 +597,92 @@ function renderUserMessage(payload, direction) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderAssistantMessage(payload) {
|
||||
const content = extractText(payload);
|
||||
function renderTraceToggleGlyph() {
|
||||
return `
|
||||
<span class="assistant-trace-glyph" aria-hidden="true">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function bindTraceToggle(toggleEl, panelEl, traceEl) {
|
||||
if (!toggleEl || !panelEl || !traceEl) return;
|
||||
toggleEl.addEventListener("click", () => {
|
||||
const expanded = toggleEl.getAttribute("aria-expanded") === "true";
|
||||
toggleEl.setAttribute("aria-expanded", expanded ? "false" : "true");
|
||||
toggleEl.classList.toggle("is-open", !expanded);
|
||||
traceEl.classList.toggle("is-expanded", !expanded);
|
||||
panelEl.classList.toggle("hidden", expanded);
|
||||
});
|
||||
}
|
||||
|
||||
function updateTraceHostDisplay(refs) {
|
||||
if (!refs) return;
|
||||
refs.traceEl.classList.toggle("hidden", refs.entryCount === 0);
|
||||
refs.countEl.textContent = String(refs.entryCount);
|
||||
refs.toggleEl.classList.toggle("has-error", refs.hasError);
|
||||
refs.row.classList.toggle("has-tool-error", refs.hasError);
|
||||
refs.toggleEl.title = refs.hasError ? "Tool trace (contains errors)" : "Tool trace";
|
||||
}
|
||||
|
||||
function createTraceHostRow(host, content = "") {
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg-row assistant";
|
||||
row.innerHTML = `<div class="msg-bubble">${formatAssistantContent(content)}</div>`;
|
||||
row.className = host.kind === "assistant" ? "msg-row assistant" : "msg-row tool-trace-row";
|
||||
row.dataset.traceHostId = host.id;
|
||||
row.innerHTML = `
|
||||
<div class="assistant-turn${host.kind === "orphan" ? " assistant-turn-orphan" : ""}">
|
||||
${content ? `<div class="msg-bubble">${formatAssistantContent(content)}</div>` : ""}
|
||||
<div class="assistant-trace hidden">
|
||||
<button type="button" class="assistant-trace-toggle" aria-expanded="false">
|
||||
${renderTraceToggleGlyph()}
|
||||
<span class="assistant-trace-count">0</span>
|
||||
<span class="assistant-trace-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
<div class="assistant-trace-panel hidden"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const traceEl = row.querySelector(".assistant-trace");
|
||||
const panelEl = row.querySelector(".assistant-trace-panel");
|
||||
const toggleEl = row.querySelector(".assistant-trace-toggle");
|
||||
const countEl = row.querySelector(".assistant-trace-count");
|
||||
|
||||
bindTraceToggle(toggleEl, panelEl, traceEl);
|
||||
|
||||
const refs = {
|
||||
hostId: host.id,
|
||||
row,
|
||||
traceEl,
|
||||
panelEl,
|
||||
toggleEl,
|
||||
countEl,
|
||||
entryCount: host.entryKinds.length,
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
traceHostElements.set(host.id, refs);
|
||||
updateTraceHostDisplay(refs);
|
||||
return row;
|
||||
}
|
||||
|
||||
function ensureTraceHostRow(host, rows = null, content = "") {
|
||||
const existing = traceHostElements.get(host.id);
|
||||
if (existing) return existing.row;
|
||||
const row = createTraceHostRow(host, content || host.assistantContent || "");
|
||||
if (Array.isArray(rows)) {
|
||||
rows.push(row);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderAssistantMessage(payload) {
|
||||
const content = extractText(payload).trim();
|
||||
const result = addAssistantToolTraceHost(toolTraceState, content);
|
||||
toolTraceState = result.state;
|
||||
return ensureTraceHostRow(result.host, null, content);
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
const text = payload.result || payload.subtype || "Session completed";
|
||||
const row = document.createElement("div");
|
||||
@@ -375,37 +691,64 @@ function renderResult(payload) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderToolCard({ titleHtml, body, isError = false }) {
|
||||
const card = document.createElement("div");
|
||||
card.className = `tool-card assistant-trace-card${isError ? " assistant-trace-card-error" : ""}`;
|
||||
card.innerHTML = `
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-icon">▶</span>
|
||||
${titleHtml}
|
||||
</div>
|
||||
<div class="tool-card-body collapsed">${esc(body)}</div>`;
|
||||
|
||||
const header = card.querySelector(".tool-card-header");
|
||||
const bodyEl = card.querySelector(".tool-card-body");
|
||||
header?.addEventListener("click", () => {
|
||||
bodyEl?.classList.toggle("collapsed");
|
||||
header.classList.toggle("is-open", !bodyEl?.classList.contains("collapsed"));
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderToolUse(payload) {
|
||||
const name = payload.tool_name || payload.name || "tool";
|
||||
const input = payload.tool_input || payload.input || {};
|
||||
const inputStr = typeof input === "string" ? input : JSON.stringify(input, null, 2);
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg-row tool";
|
||||
card.innerHTML = `
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
||||
<span class="tool-icon">▶</span> Tool: <strong>${esc(name)}</strong>
|
||||
</div>
|
||||
<div class="tool-card-body collapsed">${esc(truncate(inputStr, 2000))}</div>
|
||||
</div>`;
|
||||
return card;
|
||||
return renderToolCard({
|
||||
titleHtml: `Tool: <strong>${esc(name)}</strong>`,
|
||||
body: inputStr || "",
|
||||
});
|
||||
}
|
||||
|
||||
function renderToolResult(payload) {
|
||||
const content = payload.content || payload.output || "";
|
||||
const contentStr = typeof content === "string" ? content : JSON.stringify(content, null, 2);
|
||||
return renderToolCard({
|
||||
titleHtml: payload.is_error ? "<strong>Tool Error</strong>" : "Tool Result",
|
||||
body: contentStr || "",
|
||||
isError: !!payload.is_error,
|
||||
});
|
||||
}
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg-row tool";
|
||||
card.innerHTML = `
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
||||
<span class="tool-icon">▶</span> Tool Result
|
||||
</div>
|
||||
<div class="tool-card-body collapsed">${esc(truncate(contentStr, 2000))}</div>
|
||||
</div>`;
|
||||
return card;
|
||||
function appendToolEntryToActiveTrace(entryKind, payload, rows) {
|
||||
const result = addToolTraceEntry(toolTraceState, entryKind);
|
||||
toolTraceState = result.state;
|
||||
|
||||
if (result.createdHost) {
|
||||
ensureTraceHostRow(result.createdHost, rows);
|
||||
}
|
||||
|
||||
const refs = traceHostElements.get(result.host.id);
|
||||
if (!refs) return;
|
||||
|
||||
const card = entryKind === "use" ? renderToolUse(payload) : renderToolResult(payload);
|
||||
refs.panelEl.appendChild(card);
|
||||
refs.entryCount += 1;
|
||||
if (entryKind === "result" && payload.is_error) {
|
||||
refs.hasError = true;
|
||||
}
|
||||
updateTraceHostDisplay(refs);
|
||||
}
|
||||
|
||||
export function renderPermissionRequest(payload) {
|
||||
@@ -516,7 +859,9 @@ export function renderAskUserQuestion(payload) {
|
||||
el._answers = {};
|
||||
el._questions = questions;
|
||||
|
||||
return renderSystemMessage("Waiting for your response...");
|
||||
const status = renderSystemMessage("Waiting for your response...");
|
||||
status.dataset.pendingRequestId = requestId;
|
||||
return status;
|
||||
}
|
||||
|
||||
export function renderExitPlanMode(payload) {
|
||||
@@ -551,7 +896,7 @@ export function renderExitPlanMode(payload) {
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div class="plan-title">Ready to code?</div>
|
||||
<div class="plan-content">${formatAssistantContent(planContent)}</div>
|
||||
<div class="plan-content">${formatPlanContent(planContent)}</div>
|
||||
<div class="plan-options">
|
||||
<button class="plan-option" data-value="yes-accept-edits" onclick="window._selectPlanOption(this, 'yes-accept-edits')">
|
||||
<span class="plan-option-label">Yes, auto-accept edits</span>
|
||||
@@ -580,7 +925,9 @@ export function renderExitPlanMode(payload) {
|
||||
el._planContent = planContent;
|
||||
el._isEmpty = isEmpty;
|
||||
|
||||
return renderSystemMessage("Waiting for your response...");
|
||||
const status = renderSystemMessage("Waiting for your response...");
|
||||
status.dataset.pendingRequestId = requestId;
|
||||
return status;
|
||||
}
|
||||
|
||||
function renderSystemMessage(text) {
|
||||
@@ -594,7 +941,7 @@ function renderSystemMessage(text) {
|
||||
// Loading Indicator — TUI star spinner style
|
||||
// ============================================================
|
||||
|
||||
const LOADING_ID = "loading-indicator";
|
||||
const ACTIVITY_ID = "session-activity-indicator";
|
||||
|
||||
// TUI star spinner frames (same as Claude Code CLI)
|
||||
const SPINNER_FRAMES = ["·", "✢", "✱", "✶", "✻", "✽"];
|
||||
@@ -640,35 +987,85 @@ const SPINNER_VERBS = [
|
||||
let spinnerInterval = null;
|
||||
let timerInterval = null;
|
||||
let stalledCheckInterval = null;
|
||||
let activityCountdownInterval = null;
|
||||
let spinnerFrame = 0;
|
||||
let loadingStartTime = 0;
|
||||
let lastActivityTime = 0;
|
||||
let isStalled = false;
|
||||
let loadingActive = false;
|
||||
let workingActive = false;
|
||||
let automationActivity = null;
|
||||
|
||||
export function resolveActivityMode(working, activity) {
|
||||
if (activity?.mode === "standby" || activity?.mode === "sleeping") {
|
||||
return activity.mode;
|
||||
}
|
||||
return working ? "working" : "idle";
|
||||
}
|
||||
|
||||
export function shouldRenderTranscriptActivity(mode) {
|
||||
return mode === "working";
|
||||
}
|
||||
|
||||
export function formatCountdownRemaining(endsAt, now = Date.now()) {
|
||||
if (typeof endsAt !== "number") return "";
|
||||
|
||||
const remainingSeconds = Math.max(0, Math.ceil((endsAt - now) / 1000));
|
||||
const hours = Math.floor(remainingSeconds / 3600);
|
||||
const minutes = Math.floor((remainingSeconds % 3600) / 60);
|
||||
const seconds = remainingSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function getActivityModeInternal() {
|
||||
return resolveActivityMode(workingActive, automationActivity);
|
||||
}
|
||||
|
||||
export function isLoading() {
|
||||
return loadingActive;
|
||||
return getActivityModeInternal() === "working";
|
||||
}
|
||||
|
||||
function syncActionBtn(state) {
|
||||
if (typeof window.__updateActionBtn === "function") window.__updateActionBtn(state);
|
||||
export function getActivityMode() {
|
||||
return getActivityModeInternal();
|
||||
}
|
||||
|
||||
export function showLoading() {
|
||||
removeLoading();
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
function syncActionBtn(mode) {
|
||||
if (typeof window.__updateActionBtn === "function") window.__updateActionBtn(mode);
|
||||
}
|
||||
|
||||
loadingActive = true;
|
||||
syncActionBtn(true);
|
||||
function clearWorkingTimers() {
|
||||
if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
|
||||
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
|
||||
if (stalledCheckInterval) { clearInterval(stalledCheckInterval); stalledCheckInterval = null; }
|
||||
isStalled = false;
|
||||
}
|
||||
|
||||
function clearActivityCountdownTimer() {
|
||||
if (activityCountdownInterval) {
|
||||
clearInterval(activityCountdownInterval);
|
||||
activityCountdownInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeActivityElement() {
|
||||
const el = document.getElementById(ACTIVITY_ID);
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
function renderWorkingIndicator(stream) {
|
||||
const verb = SPINNER_VERBS[Math.floor(Math.random() * SPINNER_VERBS.length)];
|
||||
loadingStartTime = Date.now();
|
||||
lastActivityTime = Date.now();
|
||||
isStalled = false;
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.id = LOADING_ID;
|
||||
el.id = ACTIVITY_ID;
|
||||
el.className = "msg-row loading-row";
|
||||
el.innerHTML = `<span class="tui-spinner">${SPINNER_CYCLE[0]}</span><span class="tui-verb glimmer-text">${esc(verb)}…</span><span class="tui-timer">0s</span>`;
|
||||
stream.appendChild(el);
|
||||
@@ -678,14 +1075,12 @@ export function showLoading() {
|
||||
const timerEl = el.querySelector(".tui-timer");
|
||||
const loadingEl = el;
|
||||
|
||||
// Spinner animation — 120ms interval, same as TUI
|
||||
spinnerFrame = 0;
|
||||
spinnerInterval = setInterval(() => {
|
||||
spinnerFrame = (spinnerFrame + 1) % SPINNER_CYCLE.length;
|
||||
if (spinnerEl) spinnerEl.textContent = SPINNER_CYCLE[spinnerFrame];
|
||||
}, 120);
|
||||
|
||||
// Timer — update every second
|
||||
timerInterval = setInterval(() => {
|
||||
if (timerEl) {
|
||||
const elapsed = Math.floor((Date.now() - loadingStartTime) / 1000);
|
||||
@@ -693,7 +1088,6 @@ export function showLoading() {
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Stalled detection — check every 120ms (aligned with spinner)
|
||||
stalledCheckInterval = setInterval(() => {
|
||||
if (!isStalled && Date.now() - lastActivityTime > 3000) {
|
||||
isStalled = true;
|
||||
@@ -702,15 +1096,62 @@ export function showLoading() {
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function renderAutomationIndicator(stream, activity) {
|
||||
const el = document.createElement("div");
|
||||
el.id = ACTIVITY_ID;
|
||||
el.className = `msg-row automation-activity-row automation-activity-${activity.mode}`;
|
||||
el.innerHTML = `
|
||||
<div class="automation-activity-card">
|
||||
${renderAutomationIcon(activity.iconVariant, { className: "automation-activity-icon" })}
|
||||
<div class="automation-activity-copy">
|
||||
<span class="automation-activity-label">${esc(activity.label)}</span>
|
||||
</div>
|
||||
<span class="automation-activity-countdown"></span>
|
||||
</div>`;
|
||||
stream.appendChild(el);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
|
||||
const countdownEl = el.querySelector(".automation-activity-countdown");
|
||||
const updateCountdown = () => {
|
||||
if (countdownEl) {
|
||||
countdownEl.textContent = formatCountdownRemaining(activity.endsAt);
|
||||
}
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
activityCountdownInterval = setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
function renderActivityIndicator() {
|
||||
clearWorkingTimers();
|
||||
clearActivityCountdownTimer();
|
||||
removeActivityElement();
|
||||
|
||||
const mode = getActivityModeInternal();
|
||||
syncActionBtn(mode);
|
||||
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
|
||||
if (shouldRenderTranscriptActivity(mode)) {
|
||||
renderWorkingIndicator(stream);
|
||||
}
|
||||
}
|
||||
|
||||
export function setAutomationActivity(activity) {
|
||||
automationActivity = activity ? { ...activity } : null;
|
||||
renderActivityIndicator();
|
||||
}
|
||||
|
||||
export function showLoading() {
|
||||
automationActivity = null;
|
||||
workingActive = true;
|
||||
renderActivityIndicator();
|
||||
}
|
||||
|
||||
export function removeLoading() {
|
||||
if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
|
||||
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
|
||||
if (stalledCheckInterval) { clearInterval(stalledCheckInterval); stalledCheckInterval = null; }
|
||||
isStalled = false;
|
||||
loadingActive = false;
|
||||
syncActionBtn(false);
|
||||
const el = document.getElementById(LOADING_ID);
|
||||
if (el) el.remove();
|
||||
workingActive = false;
|
||||
renderActivityIndicator();
|
||||
}
|
||||
|
||||
/** Reset stalled timer — call when SSE events arrive */
|
||||
@@ -718,7 +1159,7 @@ export function refreshLoadingActivity() {
|
||||
lastActivityTime = Date.now();
|
||||
if (isStalled) {
|
||||
isStalled = false;
|
||||
const loadingEl = document.getElementById(LOADING_ID);
|
||||
const loadingEl = document.getElementById(ACTIVITY_ID);
|
||||
if (loadingEl) loadingEl.classList.remove("stalled");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ let todos = [];
|
||||
/** @type {boolean} Panel visibility */
|
||||
let panelVisible = false;
|
||||
|
||||
/** @type {boolean} Whether V2 tasks came from an authoritative snapshot */
|
||||
let hasAuthoritativeTasks = false;
|
||||
|
||||
/** @type {HTMLElement|null} Panel root element */
|
||||
let panelEl = null;
|
||||
|
||||
@@ -71,11 +74,15 @@ export function processAssistantEvent(payload) {
|
||||
const input = block.input || {};
|
||||
|
||||
if (name === "TaskCreate") {
|
||||
handleTaskCreate(input);
|
||||
changed = true;
|
||||
if (!hasAuthoritativeTasks) {
|
||||
handleTaskCreate(input);
|
||||
changed = true;
|
||||
}
|
||||
} else if (name === "TaskUpdate") {
|
||||
handleTaskUpdate(input);
|
||||
changed = true;
|
||||
if (!hasAuthoritativeTasks) {
|
||||
handleTaskUpdate(input);
|
||||
changed = true;
|
||||
}
|
||||
} else if (name === "TodoWrite") {
|
||||
handleTodoWrite(input);
|
||||
changed = true;
|
||||
@@ -167,6 +174,42 @@ function handleTodoWrite(input) {
|
||||
}));
|
||||
}
|
||||
|
||||
function replaceTasks(nextTasks) {
|
||||
tasks.clear();
|
||||
for (const task of nextTasks) {
|
||||
if (!task || typeof task !== "object" || !task.id) continue;
|
||||
tasks.set(task.id, {
|
||||
id: task.id,
|
||||
subject: task.subject || "Untitled task",
|
||||
description: task.description || "",
|
||||
activeForm: task.activeForm,
|
||||
status: task.status || "pending",
|
||||
owner: task.owner,
|
||||
blocks: Array.isArray(task.blocks) ? [...task.blocks] : [],
|
||||
blockedBy: Array.isArray(task.blockedBy) ? [...task.blockedBy] : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an authoritative task_state event from the bridge.
|
||||
* @param {{ tasks?: TaskItem[], raw?: { tasks?: TaskItem[] } }} payload
|
||||
*/
|
||||
export function applyTaskStateEvent(payload) {
|
||||
const nextTasks = Array.isArray(payload?.tasks)
|
||||
? payload.tasks
|
||||
: Array.isArray(payload?.raw?.tasks)
|
||||
? payload.raw.tasks
|
||||
: null;
|
||||
|
||||
if (!nextTasks) return;
|
||||
|
||||
hasAuthoritativeTasks = true;
|
||||
replaceTasks(nextTasks);
|
||||
renderPanel();
|
||||
updateBadge();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
@@ -177,6 +220,7 @@ function handleTodoWrite(input) {
|
||||
export function resetTaskState() {
|
||||
tasks.clear();
|
||||
todos = [];
|
||||
hasAuthoritativeTasks = false;
|
||||
if (panelEl) panelEl.innerHTML = "";
|
||||
updateBadge();
|
||||
}
|
||||
@@ -185,7 +229,7 @@ export function resetTaskState() {
|
||||
* Get current state for debugging.
|
||||
*/
|
||||
export function getTaskState() {
|
||||
return { tasks: [...tasks.values()], todos };
|
||||
return { tasks: [...tasks.values()], todos, hasAuthoritativeTasks };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
103
packages/remote-control-server/web/task-panel.test.js
Normal file
103
packages/remote-control-server/web/task-panel.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
applyTaskStateEvent,
|
||||
getTaskState,
|
||||
processAssistantEvent,
|
||||
resetTaskState,
|
||||
} from "./task-panel.js";
|
||||
|
||||
describe("task panel state", () => {
|
||||
beforeEach(() => {
|
||||
resetTaskState();
|
||||
});
|
||||
|
||||
test("falls back to assistant tool_use parsing before an authoritative snapshot arrives", () => {
|
||||
processAssistantEvent({
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "TaskUpdate",
|
||||
input: { taskId: "1", subject: "Plan fix", status: "in_progress" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getTaskState()).toEqual({
|
||||
tasks: [
|
||||
{
|
||||
id: "1",
|
||||
subject: "Plan fix",
|
||||
description: "",
|
||||
activeForm: undefined,
|
||||
status: "in_progress",
|
||||
owner: undefined,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
},
|
||||
],
|
||||
todos: [],
|
||||
hasAuthoritativeTasks: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("authoritative task_state snapshots replace tasks and stop transcript-derived task mutations", () => {
|
||||
applyTaskStateEvent({
|
||||
task_list_id: "team-alpha",
|
||||
tasks: [
|
||||
{
|
||||
id: "7",
|
||||
subject: "Real task",
|
||||
description: "Pulled from task list",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
processAssistantEvent({
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "TaskUpdate",
|
||||
input: { taskId: "99", subject: "Synthetic task", status: "completed" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "TodoWrite",
|
||||
input: {
|
||||
todos: [{ content: "Keep todo parsing", status: "pending", activeForm: "Keeping todo parsing" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getTaskState()).toEqual({
|
||||
tasks: [
|
||||
{
|
||||
id: "7",
|
||||
subject: "Real task",
|
||||
description: "Pulled from task list",
|
||||
activeForm: undefined,
|
||||
status: "pending",
|
||||
owner: undefined,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
},
|
||||
],
|
||||
todos: [
|
||||
{
|
||||
content: "Keep todo parsing",
|
||||
status: "pending",
|
||||
activeForm: "Keeping todo parsing",
|
||||
},
|
||||
],
|
||||
hasAuthoritativeTasks: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,23 @@
|
||||
* Remote Control — Shared Utilities
|
||||
*/
|
||||
|
||||
const HTML_ESCAPE_MAP = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
export function esc(str) {
|
||||
if (!str) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = String(str);
|
||||
return div.innerHTML;
|
||||
const value = String(str);
|
||||
if (typeof document !== "undefined" && typeof document.createElement === "function") {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = value;
|
||||
return div.innerHTML;
|
||||
}
|
||||
return value.replace(/[&<>"']/g, (char) => HTML_ESCAPE_MAP[char]);
|
||||
}
|
||||
|
||||
export function formatTime(ts) {
|
||||
|
||||
Reference in New Issue
Block a user