ui: refine worker console streaming
This commit is contained in:
parent
ddbcd595ef
commit
520c10d807
|
|
@ -6,7 +6,7 @@
|
|||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/markdown.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/workers.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
|
||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/chat-submit.test.ts src/lib/workspace-console/markdown.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/workers.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@
|
|||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
|
|
@ -1048,8 +1052,7 @@
|
|||
}
|
||||
|
||||
.console-log li {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
padding: 0.2rem 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
|
@ -1059,6 +1062,22 @@
|
|||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.console-plain-text {
|
||||
margin: 0.45rem 0;
|
||||
color: inherit;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.console-plain-text:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.console-plain-text:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rich-markdown > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
|
@ -1174,7 +1193,7 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
.console-log li.thinking .rich-markdown {
|
||||
.console-log li.thinking .console-plain-text {
|
||||
color: var(--tui-dark-gray);
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
@ -1197,7 +1216,20 @@
|
|||
}
|
||||
|
||||
.tool-label {
|
||||
flex: 0 0 auto;
|
||||
color: var(--tui-cyan);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-separator {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-suffix {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tool-separator,
|
||||
|
|
@ -1219,7 +1251,16 @@
|
|||
color: var(--tui-dark-gray);
|
||||
}
|
||||
|
||||
.console-line.tool .rich-markdown {
|
||||
.console-line.tool-bash .console-plain-text {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.console-line.tool .console-plain-text {
|
||||
color: var(--tui-gray);
|
||||
}
|
||||
|
||||
|
|
@ -1324,13 +1365,16 @@
|
|||
}
|
||||
|
||||
.console-composer textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 7rem;
|
||||
resize: vertical;
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 1rem;
|
||||
font: inherit;
|
||||
line-height: 1.45;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
<script lang="ts">
|
||||
import RichMarkdown from '$lib/workspace-console/RichMarkdown.svelte';
|
||||
import type { ConsoleLine } from '$lib/workspace-console/model';
|
||||
|
||||
type Props = {
|
||||
item: ConsoleLine;
|
||||
};
|
||||
|
||||
let { item }: Props = $props();
|
||||
|
||||
function lineClass(line: ConsoleLine): string {
|
||||
return line.error ? 'error' : line.kind;
|
||||
}
|
||||
|
||||
function toolClass(line: ConsoleLine): string {
|
||||
const name = line.toolCall?.name?.toLowerCase() ?? '';
|
||||
const state = line.toolCall?.state ?? (line.streaming ? 'streaming' : 'done');
|
||||
return [name ? `tool-${name}` : '', `tool-state-${state}`].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function shouldRenderHeading(line: ConsoleLine): boolean {
|
||||
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool';
|
||||
}
|
||||
|
||||
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
|
||||
const [firstLine = '', ...rest] = line.body.split('\n');
|
||||
const [label, suffix = ''] = firstLine.split(' — ', 2);
|
||||
return {
|
||||
label,
|
||||
suffix,
|
||||
rest: rest.join('\n')
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRenderMarkdown(line: ConsoleLine): boolean {
|
||||
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
|
||||
}
|
||||
|
||||
function bodyTextAfterToolSummary(line: ConsoleLine): string {
|
||||
return toolSummary(line).rest;
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class={`console-line ${lineClass(item)} ${toolClass(item)}`} class:error-line={item.error}>
|
||||
{#if shouldRenderHeading(item)}
|
||||
<div class="message-heading">
|
||||
<span>{item.title}</span>
|
||||
{#if item.streaming}<small>streaming</small>{/if}
|
||||
</div>
|
||||
{:else if item.kind === 'tool'}
|
||||
<div class="tool-summary">
|
||||
<span class="tool-label">{toolSummary(item).label}</span>
|
||||
<span class="tool-separator"> — </span>
|
||||
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
||||
{#if item.streaming}<small>streaming</small>{/if}
|
||||
</div>
|
||||
{:else if item.streaming}
|
||||
<div class="message-heading streaming-heading">
|
||||
<small>streaming</small>
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.kind === 'tool'}
|
||||
{#if bodyTextAfterToolSummary(item)}
|
||||
<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>
|
||||
{/if}
|
||||
{:else if shouldRenderMarkdown(item)}
|
||||
<RichMarkdown text={item.body || '—'} />
|
||||
{:else}
|
||||
<p class="console-plain-text">{item.body || '—'}</p>
|
||||
{/if}
|
||||
{#if item.diff}
|
||||
<pre class="console-diff" aria-label="Edit diff">{#each item.diff as diffLine}
|
||||
<span class={`diff-line ${diffLine.kind}`}><span class="diff-gutter">{diffLine.oldNumber ?? ''}</span><span class="diff-gutter">{diffLine.newNumber ?? ''}</span><span class="diff-marker">{diffLine.kind === 'add' ? '+' : diffLine.kind === 'remove' ? '-' : ' '}</span><span class="diff-content">{diffLine.content}</span></span>{/each}</pre>
|
||||
{/if}
|
||||
{#if item.detail}
|
||||
<details class="message-detail">
|
||||
<summary>detail</summary>
|
||||
<p>{item.detail}</p>
|
||||
</details>
|
||||
{/if}
|
||||
</li>
|
||||
65
web/workspace/src/lib/workspace-console/chat-submit.test.ts
Normal file
65
web/workspace/src/lib/workspace-console/chat-submit.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { shouldSubmitChatKey } from "./chat-submit.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("shouldSubmitChatKey defaults to Cmd+Enter submit behavior", () => {
|
||||
assert(
|
||||
shouldSubmitChatKey(
|
||||
{ key: "Enter", metaKey: true },
|
||||
{ mode: "mod-enter", modKey: "meta", enabled: true },
|
||||
),
|
||||
"Cmd+Enter should submit by default",
|
||||
);
|
||||
assertEquals(
|
||||
shouldSubmitChatKey(
|
||||
{ key: "Enter" },
|
||||
{ mode: "mod-enter", modKey: "meta", enabled: true },
|
||||
),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
shouldSubmitChatKey(
|
||||
{ key: "Enter", ctrlKey: true },
|
||||
{ mode: "mod-enter", modKey: "meta", enabled: true },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("shouldSubmitChatKey ignores IME composition and repeated Enter", () => {
|
||||
const options = { mode: "mod-enter" as const, modKey: "meta" as const, enabled: true };
|
||||
assertEquals(
|
||||
shouldSubmitChatKey({ key: "Enter", metaKey: true, isComposing: true }, options),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
shouldSubmitChatKey({ key: "Enter", metaKey: true, keyCode: 229 }, options),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
shouldSubmitChatKey({ key: "Enter", metaKey: true, repeat: true }, options),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("shouldSubmitChatKey supports enter submit mode", () => {
|
||||
const options = { mode: "enter" as const, modKey: "meta" as const, enabled: true };
|
||||
assert(shouldSubmitChatKey({ key: "Enter" }, options), "Enter should submit");
|
||||
assertEquals(shouldSubmitChatKey({ key: "Enter", shiftKey: true }, options), false);
|
||||
});
|
||||
144
web/workspace/src/lib/workspace-console/chat-submit.ts
Normal file
144
web/workspace/src/lib/workspace-console/chat-submit.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
export type ChatSubmitMode = "mod-enter" | "enter";
|
||||
export type ChatSubmitModKey = "meta" | "ctrl" | "auto";
|
||||
|
||||
export type ChatSubmitOptions = {
|
||||
onSubmit: (value: string, ctx: { target: HTMLTextAreaElement }) => void;
|
||||
mode?: ChatSubmitMode;
|
||||
modKey?: ChatSubmitModKey;
|
||||
allowEmptySubmit?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type NormalizedChatSubmitOptions = Required<ChatSubmitOptions>;
|
||||
|
||||
export type ChatSubmitKeyEventLike = {
|
||||
key: string;
|
||||
shiftKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
repeat?: boolean;
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
which?: number;
|
||||
};
|
||||
|
||||
function normalizeOptions(options: ChatSubmitOptions): NormalizedChatSubmitOptions {
|
||||
return {
|
||||
mode: "mod-enter",
|
||||
modKey: "meta",
|
||||
allowEmptySubmit: false,
|
||||
stopPropagation: false,
|
||||
enabled: true,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function isImeEvent(event: ChatSubmitKeyEventLike): boolean {
|
||||
return event.isComposing === true ||
|
||||
event.key === "Process" ||
|
||||
event.keyCode === 229 ||
|
||||
event.which === 229;
|
||||
}
|
||||
|
||||
function isApplePlatform(): boolean {
|
||||
if (typeof navigator === "undefined") {
|
||||
return true;
|
||||
}
|
||||
const platform = navigator.platform || "";
|
||||
const userAgent = navigator.userAgent || "";
|
||||
return /Mac|iPhone|iPad|iPod/.test(platform) || /Mac|iPhone|iPad|iPod/.test(userAgent);
|
||||
}
|
||||
|
||||
function resolveModKey(modKey: ChatSubmitModKey): "meta" | "ctrl" {
|
||||
if (modKey === "auto") {
|
||||
return isApplePlatform() ? "meta" : "ctrl";
|
||||
}
|
||||
return modKey;
|
||||
}
|
||||
|
||||
function isModPressed(event: ChatSubmitKeyEventLike, modKey: ChatSubmitModKey): boolean {
|
||||
return resolveModKey(modKey) === "meta" ? event.metaKey === true : event.ctrlKey === true;
|
||||
}
|
||||
|
||||
export function shouldSubmitChatKey(
|
||||
event: ChatSubmitKeyEventLike,
|
||||
options: Pick<NormalizedChatSubmitOptions, "mode" | "modKey" | "enabled"> & {
|
||||
isComposing?: boolean;
|
||||
},
|
||||
): boolean {
|
||||
if (!options.enabled || event.repeat || event.key !== "Enter") {
|
||||
return false;
|
||||
}
|
||||
if (options.isComposing || isImeEvent(event)) {
|
||||
return false;
|
||||
}
|
||||
if (options.mode === "enter") {
|
||||
return event.shiftKey !== true;
|
||||
}
|
||||
return isModPressed(event, options.modKey);
|
||||
}
|
||||
|
||||
export function chatSubmit(node: HTMLTextAreaElement, options: ChatSubmitOptions) {
|
||||
let current = normalizeOptions(options);
|
||||
let isComposing = false;
|
||||
|
||||
function triggerSubmit() {
|
||||
if (!current.enabled || node.disabled || node.readOnly) {
|
||||
return;
|
||||
}
|
||||
const value = node.value ?? "";
|
||||
if (!current.allowEmptySubmit && value.trim() === "") {
|
||||
return;
|
||||
}
|
||||
current.onSubmit(value, { target: node });
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (isImeEvent(event)) {
|
||||
isComposing = true;
|
||||
return;
|
||||
}
|
||||
if (!shouldSubmitChatKey(event, { ...current, isComposing })) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (current.stopPropagation) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
triggerSubmit();
|
||||
}
|
||||
|
||||
function onKeyUp(event: KeyboardEvent) {
|
||||
if (!event.isComposing) {
|
||||
isComposing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onCompositionStart() {
|
||||
isComposing = true;
|
||||
}
|
||||
|
||||
function clearComposition() {
|
||||
isComposing = false;
|
||||
}
|
||||
|
||||
node.addEventListener("keydown", onKeyDown);
|
||||
node.addEventListener("keyup", onKeyUp);
|
||||
node.addEventListener("compositionstart", onCompositionStart);
|
||||
node.addEventListener("compositionend", clearComposition);
|
||||
node.addEventListener("compositioncancel", clearComposition);
|
||||
|
||||
return {
|
||||
update(nextOptions: ChatSubmitOptions) {
|
||||
current = normalizeOptions(nextOptions);
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener("keydown", onKeyDown);
|
||||
node.removeEventListener("keyup", onKeyUp);
|
||||
node.removeEventListener("compositionstart", onCompositionStart);
|
||||
node.removeEventListener("compositionend", clearComposition);
|
||||
node.removeEventListener("compositioncancel", clearComposition);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import type { Event } from "$lib/generated/protocol";
|
||||
import { projectConsole, segmentsToText, workerConsoleHref } from "./model.ts";
|
||||
import {
|
||||
createConsoleProjector,
|
||||
projectConsole,
|
||||
segmentsToText,
|
||||
workerConsoleHref,
|
||||
} from "./model.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
|
|
@ -175,7 +180,7 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
|
|||
data: {
|
||||
id: "call-1",
|
||||
summary: "command completed",
|
||||
output: "/repo\n",
|
||||
output: "/repo\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
|
|
@ -197,12 +202,153 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
|
|||
toolLines[0].body.includes("/repo"),
|
||||
"tool result should be folded into the Call block",
|
||||
);
|
||||
assert(
|
||||
toolLines[0].body.includes("line9"),
|
||||
"Bash result preview should include the ninth output line",
|
||||
);
|
||||
assert(
|
||||
!toolLines[0].body.includes("line10") &&
|
||||
!toolLines[0].body.includes("line12"),
|
||||
"Bash result preview should be capped at ten display lines",
|
||||
);
|
||||
assert(
|
||||
toolLines[0].body.includes("… +3 more lines"),
|
||||
"Bash result preview should show omitted output count",
|
||||
);
|
||||
assert(
|
||||
toolLines[0].detail?.includes("id: call-1"),
|
||||
"call id should remain in detail",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole caps default tool request and result previews", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "70",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "custom-1",
|
||||
name: "CustomTool",
|
||||
arguments: JSON.stringify({
|
||||
first: "one",
|
||||
second: "two",
|
||||
third: "three",
|
||||
fourth: "four",
|
||||
}),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "71",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "custom-1",
|
||||
summary: "custom completed",
|
||||
output: "out1\nout2\nout3\nout4\nout5",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assertEquals(line.title, "Call · CustomTool");
|
||||
assertEquals(line.body.split("\n").length, 7);
|
||||
assert(line.body.includes("CustomTool — done"), "tool state should be shown");
|
||||
assert(line.body.includes('"first": "one"'), "request preview should be shown");
|
||||
assert(line.body.includes("out1"), "result preview should be shown");
|
||||
assert(!line.body.includes("third"), "request preview should be capped");
|
||||
assert(!line.body.includes("out3"), "result preview should be capped");
|
||||
assert(line.body.includes("… +"), "overflow marker should be shown");
|
||||
});
|
||||
|
||||
Deno.test("projectConsole shows Grep query and caps result preview to five entries", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "72",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "grep-1",
|
||||
name: "Grep",
|
||||
arguments: JSON.stringify({ pattern: "needle", path: "/repo" }),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "73",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "grep-1",
|
||||
summary: "6 matches",
|
||||
output: "hit1\nhit2\nhit3\nhit4\nhit5\nhit6",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assertEquals(line.title, "Call · Grep");
|
||||
assert(line.body.includes("Grep — 6 matches"), "Grep summary should be shown");
|
||||
assert(line.body.includes("query: needle"), "Grep query should be shown");
|
||||
assert(line.body.includes("hit1"), "first result should be shown");
|
||||
assert(line.body.includes("hit5"), "fifth result should be shown");
|
||||
assert(!line.body.includes("hit6"), "sixth result should be capped");
|
||||
assert(
|
||||
line.body.includes("… +1 more results"),
|
||||
"overflow marker should be shown",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole keeps Grep error detail in the body", () => {
|
||||
const message =
|
||||
"Tool execution failed: Invalid argument: path is outside allowed scope: /home/hare/.yoi/workdirs/working-directories/0019f5bce74f1000000/root/main/ghq.local/github/openai/codex";
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "74",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "grep-error-1",
|
||||
name: "Grep",
|
||||
arguments: JSON.stringify({ pattern: "needle", path: "/outside" }),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "75",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "grep-error-1",
|
||||
summary: message,
|
||||
output: message,
|
||||
is_error: true,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assertEquals(line.title, "Call · Grep");
|
||||
assert(
|
||||
line.body.includes("Grep — Failed"),
|
||||
"error suffix should stay short",
|
||||
);
|
||||
assert(
|
||||
line.body.includes(message),
|
||||
"error detail should remain visible in the body",
|
||||
);
|
||||
assert(
|
||||
!line.body.includes(`Grep — ${message}`),
|
||||
"error detail should not be repeated in the suffix",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
|
|
@ -232,6 +378,85 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo
|
|||
);
|
||||
});
|
||||
|
||||
Deno.test("createConsoleProjector replaces only the updated protocol block", () => {
|
||||
const projector = createConsoleProjector();
|
||||
let projection = projector.append([
|
||||
{
|
||||
eventId: "identity-1",
|
||||
event: {
|
||||
event: "user_message",
|
||||
data: { segments: [{ kind: "text", content: "hello" }] },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "identity-2",
|
||||
event: {
|
||||
event: "tool_call_start",
|
||||
data: { id: "grep-a", name: "Grep" },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "identity-3",
|
||||
event: {
|
||||
event: "tool_call_start",
|
||||
data: { id: "grep-b", name: "Grep" },
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
const userLine = projection.lines[0];
|
||||
const grepA = projection.lines[1];
|
||||
const grepB = projection.lines[2];
|
||||
|
||||
projection = projector.append([
|
||||
{
|
||||
eventId: "identity-4",
|
||||
event: {
|
||||
event: "tool_call_args_delta",
|
||||
data: { id: "grep-a", json: '{"pattern":"needle"}' },
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assert(
|
||||
projection.lines[0] === userLine,
|
||||
"unrelated message line should keep object identity",
|
||||
);
|
||||
assert(
|
||||
projection.lines[1] !== grepA,
|
||||
"updated tool line should get a new object identity",
|
||||
);
|
||||
assert(
|
||||
projection.lines[2] === grepB,
|
||||
"parallel unrelated tool line should keep object identity",
|
||||
);
|
||||
|
||||
const updatedGrepA = projection.lines[1];
|
||||
projection = projector.append([
|
||||
{
|
||||
eventId: "identity-5",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "grep-b",
|
||||
summary: "done",
|
||||
output: "hit",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assert(
|
||||
projection.lines[1] === updatedGrepA,
|
||||
"previously updated but now unrelated tool line should keep identity",
|
||||
);
|
||||
assert(
|
||||
projection.lines[2] !== grepB,
|
||||
"completed parallel tool line should get a new object identity",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole aggregates Read calls without showing file content", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -95,15 +95,42 @@ export function workerConsolePath(
|
|||
|
||||
export type ConsoleEventInput = { eventId: string; event: ProtocolEvent };
|
||||
|
||||
export function projectConsole(
|
||||
events: ConsoleEventInput[] = [],
|
||||
): ConsoleProjection {
|
||||
const projection = events.reduce(applyProtocolEvent, {
|
||||
export function emptyConsoleProjection(): ConsoleProjection {
|
||||
return {
|
||||
lines: [],
|
||||
status: null,
|
||||
usage: null,
|
||||
lastEventId: null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function projectConsole(
|
||||
events: ConsoleEventInput[] = [],
|
||||
): ConsoleProjection {
|
||||
const projection = events.reduce(applyProtocolEvent, emptyConsoleProjection());
|
||||
return projectVisibleConsole(projection);
|
||||
}
|
||||
|
||||
export function createConsoleProjector() {
|
||||
let projection = emptyConsoleProjection();
|
||||
return {
|
||||
reset(): ConsoleProjection {
|
||||
projection = emptyConsoleProjection();
|
||||
return projectVisibleConsole(projection);
|
||||
},
|
||||
append(events: ConsoleEventInput[]): ConsoleProjection {
|
||||
for (const event of events) {
|
||||
projection = applyProtocolEvent(projection, event);
|
||||
}
|
||||
return projectVisibleConsole(projection);
|
||||
},
|
||||
snapshot(): ConsoleProjection {
|
||||
return projectVisibleConsole(projection);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function projectVisibleConsole(projection: ConsoleProjection): ConsoleProjection {
|
||||
return {
|
||||
...projection,
|
||||
lines: aggregateReadToolLines(projection.lines),
|
||||
|
|
@ -314,6 +341,18 @@ function line(
|
|||
};
|
||||
}
|
||||
|
||||
function findLastLineIndex(
|
||||
lines: ConsoleLine[],
|
||||
predicate: (line: ConsoleLine) => boolean,
|
||||
): number {
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
if (predicate(lines[index])) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function appendStreaming(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
|
|
@ -321,12 +360,17 @@ function appendStreaming(
|
|||
title: string,
|
||||
delta: string,
|
||||
): void {
|
||||
const existing = [...projection.lines].reverse().find((item) =>
|
||||
item.kind === kind && item.streaming
|
||||
const index = findLastLineIndex(
|
||||
projection.lines,
|
||||
(item) => item.kind === kind && item.streaming === true,
|
||||
);
|
||||
if (existing) {
|
||||
existing.body += delta;
|
||||
existing.eventId = eventId;
|
||||
if (index >= 0) {
|
||||
const existing = projection.lines[index];
|
||||
projection.lines[index] = {
|
||||
...existing,
|
||||
body: `${existing.body}${delta}`,
|
||||
eventId,
|
||||
};
|
||||
return;
|
||||
}
|
||||
projection.lines.push(line(eventId, kind, title, delta, undefined, true));
|
||||
|
|
@ -339,14 +383,19 @@ function finalizeStreaming(
|
|||
title: string,
|
||||
body: string,
|
||||
): void {
|
||||
const existing = [...projection.lines].reverse().find((item) =>
|
||||
item.kind === kind && item.streaming
|
||||
const index = findLastLineIndex(
|
||||
projection.lines,
|
||||
(item) => item.kind === kind && item.streaming === true,
|
||||
);
|
||||
if (existing) {
|
||||
existing.body = body || existing.body;
|
||||
existing.streaming = false;
|
||||
existing.title = title;
|
||||
existing.eventId = eventId;
|
||||
if (index >= 0) {
|
||||
const existing = projection.lines[index];
|
||||
projection.lines[index] = {
|
||||
...existing,
|
||||
body: body || existing.body,
|
||||
streaming: false,
|
||||
title,
|
||||
eventId,
|
||||
};
|
||||
return;
|
||||
}
|
||||
projection.lines.push(line(eventId, kind, title, body));
|
||||
|
|
@ -358,9 +407,9 @@ function upsertToolCall(
|
|||
id: string,
|
||||
update: Partial<Omit<ToolCallView, "id">>,
|
||||
): ConsoleLine {
|
||||
let existing = findToolCallLine(projection, id);
|
||||
if (!existing) {
|
||||
existing = toolLine(eventId, {
|
||||
const index = findToolCallLineIndex(projection, id);
|
||||
if (index < 0) {
|
||||
const created = toolLine(eventId, {
|
||||
id,
|
||||
name: update.name ?? "Tool",
|
||||
argsStream: update.argsStream ?? "",
|
||||
|
|
@ -370,18 +419,23 @@ function upsertToolCall(
|
|||
output: update.output,
|
||||
isError: update.isError,
|
||||
});
|
||||
projection.lines.push(existing);
|
||||
} else {
|
||||
existing.eventId = eventId;
|
||||
existing.toolCall = {
|
||||
projection.lines.push(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
const existing = projection.lines[index];
|
||||
const updated = refreshedToolLine({
|
||||
...existing,
|
||||
eventId,
|
||||
toolCall: {
|
||||
...existing.toolCall!,
|
||||
...update,
|
||||
id,
|
||||
name: update.name ?? existing.toolCall!.name,
|
||||
};
|
||||
}
|
||||
refreshToolLine(existing);
|
||||
return existing;
|
||||
},
|
||||
});
|
||||
projection.lines[index] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
function appendToolArgs(
|
||||
|
|
@ -390,11 +444,27 @@ function appendToolArgs(
|
|||
id: string,
|
||||
delta: string,
|
||||
): void {
|
||||
const existing = upsertToolCall(projection, eventId, id, {
|
||||
state: "streaming_args",
|
||||
const index = findToolCallLineIndex(projection, id);
|
||||
if (index < 0) {
|
||||
projection.lines.push(toolLine(eventId, {
|
||||
id,
|
||||
name: "Tool",
|
||||
argsStream: delta,
|
||||
state: "streaming_args",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const existing = projection.lines[index];
|
||||
const toolCall = existing.toolCall!;
|
||||
projection.lines[index] = refreshedToolLine({
|
||||
...existing,
|
||||
eventId,
|
||||
toolCall: {
|
||||
...toolCall,
|
||||
argsStream: `${toolCall.argsStream}${delta}`,
|
||||
state: "streaming_args",
|
||||
},
|
||||
});
|
||||
existing.toolCall!.argsStream += delta;
|
||||
refreshToolLine(existing);
|
||||
}
|
||||
|
||||
function attachToolResult(
|
||||
|
|
@ -403,42 +473,47 @@ function attachToolResult(
|
|||
id: string,
|
||||
result: Pick<ToolCallView, "summary" | "output" | "isError">,
|
||||
): void {
|
||||
const existing = findToolCallLine(projection, id);
|
||||
if (!existing) {
|
||||
const fallback = toolLine(eventId, {
|
||||
id,
|
||||
name: "Tool",
|
||||
argsStream: "",
|
||||
state: result.isError ? "error" : "done",
|
||||
...result,
|
||||
const index = findToolCallLineIndex(projection, id);
|
||||
if (index < 0) {
|
||||
const fallback = refreshedToolLine({
|
||||
...toolLine(eventId, {
|
||||
id,
|
||||
name: "Tool",
|
||||
argsStream: "",
|
||||
state: result.isError ? "error" : "done",
|
||||
...result,
|
||||
}),
|
||||
title: result.isError ? "Call · Tool result error" : "Call · Tool result",
|
||||
});
|
||||
fallback.title = result.isError
|
||||
? "Call · Tool result error"
|
||||
: "Call · Tool result";
|
||||
refreshToolLine(fallback);
|
||||
projection.lines.push(fallback);
|
||||
return;
|
||||
}
|
||||
existing.eventId = eventId;
|
||||
existing.toolCall = {
|
||||
...existing.toolCall!,
|
||||
...result,
|
||||
state: result.isError ? "error" : "done",
|
||||
};
|
||||
refreshToolLine(existing);
|
||||
const existing = projection.lines[index];
|
||||
projection.lines[index] = refreshedToolLine({
|
||||
...existing,
|
||||
eventId,
|
||||
toolCall: {
|
||||
...existing.toolCall!,
|
||||
...result,
|
||||
state: result.isError ? "error" : "done",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function findToolCallLine(
|
||||
function findToolCallLineIndex(
|
||||
projection: ConsoleProjection,
|
||||
id: string,
|
||||
): ConsoleLine | undefined {
|
||||
return [...projection.lines].reverse().find((item) =>
|
||||
item.toolCall?.id === id
|
||||
);
|
||||
): number {
|
||||
for (let index = projection.lines.length - 1; index >= 0; index -= 1) {
|
||||
if (projection.lines[index].toolCall?.id === id) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function toolLine(eventId: string, toolCall: ToolCallView): ConsoleLine {
|
||||
const item: ConsoleLine = {
|
||||
return refreshedToolLine({
|
||||
id: `tool-call-${toolCall.id}`,
|
||||
kind: "tool",
|
||||
title: `Call · ${toolCall.name}`,
|
||||
|
|
@ -449,24 +524,25 @@ function toolLine(eventId: string, toolCall: ToolCallView): ConsoleLine {
|
|||
streaming: true,
|
||||
error: false,
|
||||
toolCall,
|
||||
};
|
||||
refreshToolLine(item);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function refreshToolLine(item: ConsoleLine): void {
|
||||
function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
||||
const toolCall = item.toolCall;
|
||||
if (!toolCall) {
|
||||
return;
|
||||
return item;
|
||||
}
|
||||
item.title = item.title.startsWith("Call · Tool result")
|
||||
? item.title
|
||||
: `Call · ${toolCall.name}`;
|
||||
item.body = renderToolCall(toolCall);
|
||||
item.detail = toolCallDetail(toolCall);
|
||||
item.diff = toolCall.name === "Edit" ? editDiff(toolCall) : undefined;
|
||||
item.streaming = !["done", "error"].includes(toolCall.state);
|
||||
item.error = toolCall.state === "error";
|
||||
return {
|
||||
...item,
|
||||
title: item.title.startsWith("Call · Tool result")
|
||||
? item.title
|
||||
: `Call · ${toolCall.name}`,
|
||||
body: renderToolCall(toolCall),
|
||||
detail: toolCallDetail(toolCall),
|
||||
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
||||
streaming: !["done", "error"].includes(toolCall.state),
|
||||
error: toolCall.state === "error",
|
||||
};
|
||||
}
|
||||
|
||||
function renderToolCall(toolCall: ToolCallView): string {
|
||||
|
|
@ -478,8 +554,9 @@ function renderToolCall(toolCall: ToolCallView): string {
|
|||
case "Edit":
|
||||
return renderEditTool(toolCall);
|
||||
case "Glob":
|
||||
case "Grep":
|
||||
return renderSearchTool(toolCall);
|
||||
case "Grep":
|
||||
return renderGrepTool(toolCall);
|
||||
case "Bash":
|
||||
return renderBashTool(toolCall);
|
||||
default:
|
||||
|
|
@ -665,28 +742,55 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
|
|||
function renderSearchTool(toolCall: ToolCallView): string {
|
||||
const summary = toolCall.summary?.trim();
|
||||
return compactLines([
|
||||
`${toolCall.name} — ${
|
||||
summary ? firstLine(summary) : stateSuffix(toolCall.state)
|
||||
}`,
|
||||
`${toolCall.name} — ${toolHeaderSuffix(toolCall, summary)}`,
|
||||
resultText(toolCall),
|
||||
]);
|
||||
}
|
||||
|
||||
function renderGrepTool(toolCall: ToolCallView): string {
|
||||
const summary = toolCall.summary?.trim();
|
||||
return compactLines([
|
||||
`Grep — ${toolHeaderSuffix(toolCall, summary)}`,
|
||||
grepQueryText(toolCall),
|
||||
cappedResultSection(resultText(toolCall), 5),
|
||||
]);
|
||||
}
|
||||
|
||||
function toolHeaderSuffix(
|
||||
toolCall: ToolCallView,
|
||||
summary?: string,
|
||||
): string {
|
||||
if (toolCall.state === "error") {
|
||||
return "Failed";
|
||||
}
|
||||
return summary ? firstLine(summary) : stateSuffix(toolCall.state);
|
||||
}
|
||||
|
||||
function grepQueryText(toolCall: ToolCallView): string | undefined {
|
||||
const args = parsedArgs(toolCall);
|
||||
const pattern = stringField(args, "pattern");
|
||||
if (pattern) {
|
||||
return `query: ${pattern}`;
|
||||
}
|
||||
const renderedArgs = argsText(toolCall);
|
||||
return renderedArgs ? `query:\n${renderedArgs}` : undefined;
|
||||
}
|
||||
|
||||
function renderBashTool(toolCall: ToolCallView): string {
|
||||
const args = parsedArgs(toolCall);
|
||||
const command = stringField(args, "command");
|
||||
return compactLines([
|
||||
`Bash — ${stateSuffix(toolCall.state)}`,
|
||||
command ? `$ ${command}` : argsText(toolCall),
|
||||
resultText(toolCall),
|
||||
cappedDisplaySection(resultText(toolCall), 10),
|
||||
]);
|
||||
}
|
||||
|
||||
function renderDefaultTool(toolCall: ToolCallView): string {
|
||||
return compactLines([
|
||||
`${toolCall.name} — ${stateSuffix(toolCall.state)}`,
|
||||
argsText(toolCall),
|
||||
resultText(toolCall),
|
||||
cappedDisplaySection(argsText(toolCall), 3),
|
||||
cappedDisplaySection(resultText(toolCall), 3),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -754,6 +858,42 @@ function cappedSection(
|
|||
return shown.join("\n");
|
||||
}
|
||||
|
||||
function cappedDisplaySection(
|
||||
value: string | undefined,
|
||||
maxLines: number,
|
||||
): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = value.split(/\r?\n/);
|
||||
if (lines.length <= maxLines) {
|
||||
return value;
|
||||
}
|
||||
if (maxLines <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const shown = lines.slice(0, maxLines);
|
||||
shown[maxLines - 1] = `… +${lines.length - maxLines + 1} more lines`;
|
||||
return shown.join("\n");
|
||||
}
|
||||
|
||||
function cappedResultSection(
|
||||
value: string | undefined,
|
||||
maxResults: number,
|
||||
): string | undefined {
|
||||
if (!value || maxResults <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = value.split(/\r?\n/).filter((line) => line.length > 0);
|
||||
if (lines.length <= maxResults) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
return [
|
||||
...lines.slice(0, maxResults),
|
||||
`… +${lines.length - maxResults} more results`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function usageText(
|
||||
data: {
|
||||
input_tokens: number | null;
|
||||
|
|
|
|||
87
web/workspace/src/lib/workspace-console/textarea-fit.ts
Normal file
87
web/workspace/src/lib/workspace-console/textarea-fit.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
export type TextareaFitOptions = {
|
||||
value?: string;
|
||||
maxRows?: number;
|
||||
};
|
||||
|
||||
function positiveNumber(value: number | undefined, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function pixelValue(value: string): number {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function lineHeightPx(style: CSSStyleDeclaration): number {
|
||||
const parsed = pixelValue(style.lineHeight);
|
||||
if (parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
const fontSize = pixelValue(style.fontSize);
|
||||
return fontSize > 0 ? fontSize * 1.2 : 16.8;
|
||||
}
|
||||
|
||||
function verticalBoxPx(style: CSSStyleDeclaration): number {
|
||||
return pixelValue(style.paddingTop) +
|
||||
pixelValue(style.paddingBottom) +
|
||||
pixelValue(style.borderTopWidth) +
|
||||
pixelValue(style.borderBottomWidth);
|
||||
}
|
||||
|
||||
function fitTextareaHeight(node: HTMLTextAreaElement, maxRows: number) {
|
||||
const style = globalThis.getComputedStyle(node);
|
||||
const maxHeight = lineHeightPx(style) * maxRows + verticalBoxPx(style);
|
||||
node.style.height = "auto";
|
||||
const nextHeight = Math.min(node.scrollHeight, maxHeight);
|
||||
node.style.height = `${nextHeight}px`;
|
||||
node.style.overflowY = node.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
}
|
||||
|
||||
export function fitTextarea(
|
||||
node: HTMLTextAreaElement,
|
||||
options: TextareaFitOptions = {},
|
||||
) {
|
||||
let current = {
|
||||
value: options.value,
|
||||
maxRows: positiveNumber(options.maxRows, 10),
|
||||
};
|
||||
let frame: number | null = null;
|
||||
|
||||
function scheduleFit() {
|
||||
if (frame !== null) {
|
||||
return;
|
||||
}
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
fitTextareaHeight(node, current.maxRows);
|
||||
});
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
scheduleFit();
|
||||
}
|
||||
|
||||
node.addEventListener("input", onInput);
|
||||
scheduleFit();
|
||||
|
||||
return {
|
||||
update(nextOptions: TextareaFitOptions = {}) {
|
||||
const next = {
|
||||
value: nextOptions.value,
|
||||
maxRows: positiveNumber(nextOptions.maxRows, 10),
|
||||
};
|
||||
if (next.value !== current.value || next.maxRows !== current.maxRows) {
|
||||
current = next;
|
||||
scheduleFit();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
if (frame !== null) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
node.removeEventListener("input", onInput);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -69,13 +69,49 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
|
|||
consolePage.includes(
|
||||
"rememberObservationEvent(frame.envelope.event_id)",
|
||||
) &&
|
||||
consolePage.includes("projectConsole(observedEvents.map") &&
|
||||
consolePage.includes("createConsoleProjector") &&
|
||||
consolePage.includes("consoleProjector.append(eventBatch)") &&
|
||||
consolePage.includes("{#each lines as item (item.id)}") &&
|
||||
!consolePage.includes("projectConsole(observedEvents.map") &&
|
||||
!consolePage.includes("/transcript") &&
|
||||
!consolePage.includes("WorkerTranscriptProjection"),
|
||||
"Console should render protocol observation replay/live events directly and dedupe repeated frames by event id",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console renders markdown only for message rows", async () => {
|
||||
const consoleLine = await Deno.readTextFile(
|
||||
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
consoleLine.includes("function shouldRenderMarkdown") &&
|
||||
consoleLine.includes("item.kind === 'tool'") &&
|
||||
consoleLine.includes('<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>') &&
|
||||
consoleLine.includes("{:else if shouldRenderMarkdown(item)}") &&
|
||||
consoleLine.includes("<RichMarkdown text={item.body || '—'} />"),
|
||||
"Console should keep markdown rendering to user/assistant/system message bodies and render tool text literally",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console composer fits to content without manual resize", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const css = await Deno.readTextFile(new URL("../../app.css", import.meta.url));
|
||||
|
||||
assert(
|
||||
consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") &&
|
||||
css.includes(".console-composer textarea") &&
|
||||
css.includes("resize: none") &&
|
||||
css.includes("overflow-y: hidden"),
|
||||
"Console composer should autosize to content, cap at ten rows, and disable manual resize",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
|
||||
const editor = await Deno.readTextFile(
|
||||
new URL(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import RichMarkdown from '$lib/workspace-console/RichMarkdown.svelte';
|
||||
import ConsoleLineItem from '$lib/workspace-console/ConsoleLineItem.svelte';
|
||||
import { chatSubmit } from '$lib/workspace-console/chat-submit';
|
||||
import { fitTextarea } from '$lib/workspace-console/textarea-fit';
|
||||
import {
|
||||
projectConsole,
|
||||
type ConsoleLine
|
||||
createConsoleProjector,
|
||||
type ConsoleEventInput,
|
||||
type ConsoleProjection
|
||||
} from '$lib/workspace-console/model';
|
||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||
import type {
|
||||
|
|
@ -44,8 +47,13 @@
|
|||
let consoleBodyElement: HTMLElement | null = null;
|
||||
let autoFollowConsole = $state(true);
|
||||
const CONSOLE_BOTTOM_THRESHOLD_PX = 48;
|
||||
let observedEvents = $state<Array<{ eventId: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
|
||||
const consoleProjector = createConsoleProjector();
|
||||
let consoleProjection = $state.raw<ConsoleProjection>(consoleProjector.snapshot());
|
||||
let seenObservationEventIds = new Set<string>();
|
||||
let pendingObservationEvents: ConsoleEventInput[] = [];
|
||||
let pendingObservedStates: Array<string | null> = [];
|
||||
let pendingStreamDiagnostics: Diagnostic[] = [];
|
||||
let observationFlushHandle: number | null = null;
|
||||
let nextReloadToken = 0;
|
||||
let reloadToken = $state(0);
|
||||
|
||||
|
|
@ -56,10 +64,7 @@
|
|||
|
||||
const consoleTarget = $derived({ runtimeId, workerId });
|
||||
|
||||
const projection = $derived(
|
||||
projectConsole(observedEvents.map((item) => ({ eventId: item.eventId, event: item.event.envelope.payload })))
|
||||
);
|
||||
const lines = $derived(projection.lines);
|
||||
const lines = $derived(consoleProjection.lines);
|
||||
const diagnostics = $derived(mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics));
|
||||
const workerState = $derived(liveWorkerState ?? worker?.state ?? 'loading');
|
||||
const inputReady = $derived(workerState === 'idle');
|
||||
|
|
@ -124,10 +129,69 @@
|
|||
}
|
||||
|
||||
function resetObservedEvents() {
|
||||
observedEvents = [];
|
||||
cancelObservationFlush();
|
||||
consoleProjection = consoleProjector.reset();
|
||||
seenObservationEventIds = new Set();
|
||||
}
|
||||
|
||||
function cancelObservationFlush() {
|
||||
if (observationFlushHandle !== null) {
|
||||
window.cancelAnimationFrame(observationFlushHandle);
|
||||
observationFlushHandle = null;
|
||||
}
|
||||
pendingObservationEvents = [];
|
||||
pendingObservedStates = [];
|
||||
pendingStreamDiagnostics = [];
|
||||
}
|
||||
|
||||
function scheduleObservationFlush() {
|
||||
if (observationFlushHandle !== null) {
|
||||
return;
|
||||
}
|
||||
observationFlushHandle = window.requestAnimationFrame(() => {
|
||||
flushObservationBatch();
|
||||
});
|
||||
}
|
||||
|
||||
function flushObservationBatch() {
|
||||
observationFlushHandle = null;
|
||||
const eventBatch = pendingObservationEvents;
|
||||
const stateBatch = pendingObservedStates;
|
||||
const diagnosticBatch = pendingStreamDiagnostics;
|
||||
pendingObservationEvents = [];
|
||||
pendingObservedStates = [];
|
||||
pendingStreamDiagnostics = [];
|
||||
|
||||
if (eventBatch.length > 0) {
|
||||
const latestState = stateBatch.findLast((state) => state !== null);
|
||||
if (latestState) {
|
||||
liveWorkerState = latestState;
|
||||
}
|
||||
consoleProjection = consoleProjector.append(eventBatch);
|
||||
}
|
||||
|
||||
if (diagnosticBatch.length > 0) {
|
||||
streamDiagnostics = [...streamDiagnostics, ...diagnosticBatch];
|
||||
}
|
||||
}
|
||||
|
||||
function queueObservationEvent(frame: ClientWorkerEventWsFrame & { kind: 'event' }) {
|
||||
if (!rememberObservationEvent(frame.envelope.event_id)) {
|
||||
return;
|
||||
}
|
||||
pendingObservationEvents.push({
|
||||
eventId: frame.envelope.event_id,
|
||||
event: frame.envelope.payload
|
||||
});
|
||||
pendingObservedStates.push(workerStateFromProtocolEvent(frame.envelope.payload));
|
||||
scheduleObservationFlush();
|
||||
}
|
||||
|
||||
function queueObservationDiagnostic(diagnostic: Diagnostic) {
|
||||
pendingStreamDiagnostics.push(diagnostic);
|
||||
scheduleObservationFlush();
|
||||
}
|
||||
|
||||
function rememberObservationEvent(eventId: string): boolean {
|
||||
if (seenObservationEventIds.has(eventId)) {
|
||||
return false;
|
||||
|
|
@ -136,9 +200,8 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
async function sendMessage(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const content = draft.trim();
|
||||
async function submitDraft(value = draft) {
|
||||
const content = value.trim();
|
||||
if (!content || sending || !inputReady) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -163,6 +226,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function sendMessage(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
await submitDraft();
|
||||
}
|
||||
|
||||
function workerStateFromProtocolEvent(event: PodProtocolEvent): string | null {
|
||||
switch (event.event) {
|
||||
case 'snapshot':
|
||||
|
|
@ -199,39 +267,20 @@
|
|||
try {
|
||||
const frame = JSON.parse(String(message.data)) as ClientWorkerEventWsFrame;
|
||||
if (frame.kind === 'event') {
|
||||
if (!rememberObservationEvent(frame.envelope.event_id)) {
|
||||
return;
|
||||
}
|
||||
const observedState = workerStateFromProtocolEvent(frame.envelope.payload);
|
||||
if (observedState) {
|
||||
liveWorkerState = observedState;
|
||||
}
|
||||
observedEvents = [
|
||||
...observedEvents,
|
||||
{
|
||||
eventId: frame.envelope.event_id,
|
||||
event: frame
|
||||
}
|
||||
].slice(-500);
|
||||
queueObservationEvent(frame);
|
||||
} else {
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: frame.diagnostic.code,
|
||||
severity: 'warning',
|
||||
message: frame.diagnostic.message
|
||||
}
|
||||
];
|
||||
queueObservationDiagnostic({
|
||||
code: frame.diagnostic.code,
|
||||
severity: 'warning',
|
||||
message: frame.diagnostic.message
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: 'worker_observation_frame_invalid',
|
||||
severity: 'warning',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
];
|
||||
queueObservationDiagnostic({
|
||||
code: 'worker_observation_frame_invalid',
|
||||
severity: 'warning',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
|
|
@ -264,34 +313,6 @@
|
|||
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
|
||||
}
|
||||
|
||||
function lineClass(line: ConsoleLine): string {
|
||||
return line.error ? 'error' : line.kind;
|
||||
}
|
||||
|
||||
function toolClass(line: ConsoleLine): string {
|
||||
const name = line.toolCall?.name?.toLowerCase() ?? '';
|
||||
const state = line.toolCall?.state ?? (line.streaming ? 'streaming' : 'done');
|
||||
return [name ? `tool-${name}` : '', `tool-state-${state}`].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function shouldRenderHeading(line: ConsoleLine): boolean {
|
||||
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool';
|
||||
}
|
||||
|
||||
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
|
||||
const [firstLine = '', ...rest] = line.body.split('\n');
|
||||
const [label, suffix = ''] = firstLine.split(' — ', 2);
|
||||
return {
|
||||
label,
|
||||
suffix,
|
||||
rest: rest.join('\n')
|
||||
};
|
||||
}
|
||||
|
||||
function bodyTextAfterToolSummary(line: ConsoleLine): string {
|
||||
return toolSummary(line).rest;
|
||||
}
|
||||
|
||||
function isNearConsoleBottom(element: HTMLElement): boolean {
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD_PX;
|
||||
}
|
||||
|
|
@ -359,11 +380,11 @@
|
|||
|
||||
<section class="console-body" bind:this={consoleBodyElement} onscroll={handleConsoleScroll}>
|
||||
<article class="card console-card worker-console-card">
|
||||
{#if projection.status || projection.usage}
|
||||
{#if consoleProjection.status || consoleProjection.usage}
|
||||
<p class="section-note">
|
||||
{#if projection.status}status: {projection.status}{/if}
|
||||
{#if projection.status && projection.usage} · {/if}
|
||||
{#if projection.usage}usage: {projection.usage}{/if}
|
||||
{#if consoleProjection.status}status: {consoleProjection.status}{/if}
|
||||
{#if consoleProjection.status && consoleProjection.usage} · {/if}
|
||||
{#if consoleProjection.usage}usage: {consoleProjection.usage}{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
|
@ -375,43 +396,8 @@
|
|||
<p>No console output is available for this Worker yet.</p>
|
||||
{:else}
|
||||
<ol class="console-log">
|
||||
{#each lines as item}
|
||||
<li class={`console-line ${lineClass(item)} ${toolClass(item)}`} class:error-line={item.error}>
|
||||
{#if shouldRenderHeading(item)}
|
||||
<div class="message-heading">
|
||||
<span>{item.title}</span>
|
||||
{#if item.streaming}<small>streaming</small>{/if}
|
||||
</div>
|
||||
{:else if item.kind === 'tool'}
|
||||
<div class="tool-summary">
|
||||
<span class="tool-label">{toolSummary(item).label}</span>
|
||||
<span class="tool-separator"> — </span>
|
||||
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
||||
{#if item.streaming}<small>streaming</small>{/if}
|
||||
</div>
|
||||
{:else if item.streaming}
|
||||
<div class="message-heading streaming-heading">
|
||||
<small>streaming</small>
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.kind === 'tool'}
|
||||
{#if bodyTextAfterToolSummary(item)}
|
||||
<RichMarkdown text={bodyTextAfterToolSummary(item)} />
|
||||
{/if}
|
||||
{:else}
|
||||
<RichMarkdown text={item.body || '—'} />
|
||||
{/if}
|
||||
{#if item.diff}
|
||||
<pre class="console-diff" aria-label="Edit diff">{#each item.diff as diffLine}
|
||||
<span class={`diff-line ${diffLine.kind}`}><span class="diff-gutter">{diffLine.oldNumber ?? ''}</span><span class="diff-gutter">{diffLine.newNumber ?? ''}</span><span class="diff-marker">{diffLine.kind === 'add' ? '+' : diffLine.kind === 'remove' ? '-' : ' '}</span><span class="diff-content">{diffLine.content}</span></span>{/each}</pre>
|
||||
{/if}
|
||||
{#if item.detail}
|
||||
<details class="message-detail">
|
||||
<summary>detail</summary>
|
||||
<p>{item.detail}</p>
|
||||
</details>
|
||||
{/if}
|
||||
</li>
|
||||
{#each lines as item (item.id)}
|
||||
<ConsoleLineItem {item} />
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
|
@ -484,7 +470,13 @@
|
|||
<textarea
|
||||
id="worker-console-message"
|
||||
aria-label="Console input"
|
||||
aria-keyshortcuts="Meta+Enter"
|
||||
bind:value={draft}
|
||||
use:chatSubmit={{
|
||||
enabled: inputReady && !sending,
|
||||
onSubmit: (value) => void submitDraft(value)
|
||||
}}
|
||||
use:fitTextarea={{ value: draft, maxRows: 10 }}
|
||||
disabled={!inputReady || sending}
|
||||
></textarea>
|
||||
<div class="composer-actions">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user