feat: add worker console timeline
This commit is contained in:
+1129
-1905
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class={`console-line ${lineClass(item)} ${toolClass(item)}`} class:error-line={item.error}>
|
||||
<li
|
||||
class={`console-line ${lineClass(item)} ${toolClass(item)}`}
|
||||
class:error-line={item.error}
|
||||
data-console-line-id={item.id}
|
||||
>
|
||||
{#if shouldRenderHeading(item)}
|
||||
<div class="message-heading">
|
||||
<span>{item.title}</span>
|
||||
@@ -79,3 +83,200 @@
|
||||
</details>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.console-line {
|
||||
min-width: 0;
|
||||
padding: 0.2rem 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.console-line.error-line {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.console-line.user {
|
||||
color: var(--tui-green);
|
||||
}
|
||||
|
||||
.console-line.assistant {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.console-line.thinking .message-heading {
|
||||
color: var(--tui-magenta);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.console-line.thinking .console-plain-text {
|
||||
color: var(--tui-dark-gray);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.tool-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.tool-summary small {
|
||||
margin-left: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.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,
|
||||
.tool-suffix {
|
||||
color: var(--tui-dark-gray);
|
||||
}
|
||||
|
||||
.tool-state-error .tool-suffix {
|
||||
color: var(--tui-red);
|
||||
}
|
||||
|
||||
.tool-state-running .tool-suffix,
|
||||
.tool-state-streaming_args .tool-suffix,
|
||||
.tool-state-pending .tool-suffix {
|
||||
color: var(--tui-yellow);
|
||||
}
|
||||
|
||||
.tool-state-done .tool-suffix {
|
||||
color: var(--tui-dark-gray);
|
||||
}
|
||||
|
||||
.message-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.message-heading.streaming-heading {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-heading small {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.console-diff {
|
||||
background: color-mix(in oklch, var(--bg-raised) 85%, black);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
margin: 0.6rem 0 0;
|
||||
overflow-x: auto;
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: grid;
|
||||
grid-template-columns: 3.2rem 3.2rem 1.4rem minmax(0, 1fr);
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.diff-line.add {
|
||||
background: color-mix(in oklch, var(--tui-green) 18%, transparent);
|
||||
color: color-mix(in oklch, var(--tui-green) 75%, white);
|
||||
}
|
||||
|
||||
.diff-line.remove {
|
||||
background: color-mix(in oklch, var(--tui-red) 18%, transparent);
|
||||
color: color-mix(in oklch, var(--tui-red) 72%, white);
|
||||
}
|
||||
|
||||
.diff-line.context {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.diff-gutter,
|
||||
.diff-marker {
|
||||
color: var(--tui-gray);
|
||||
padding: 0 0.5rem;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diff-content {
|
||||
padding-right: 0.75rem;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
:global(.console-line pre) {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
color: var(--code);
|
||||
}
|
||||
|
||||
.message-detail {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.message-detail summary {
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
type TimelineKind = 'turn' | 'assistant';
|
||||
|
||||
export type TimelineMark = {
|
||||
id: string;
|
||||
lineId: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
timeLabel: string;
|
||||
position: number;
|
||||
sourcePosition: number;
|
||||
kind: TimelineKind;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
marks: TimelineMark[];
|
||||
thumbStyle: string;
|
||||
axisStyle: string;
|
||||
onRailClick: (event: MouseEvent) => void;
|
||||
onMarkClick: (mark: TimelineMark) => void;
|
||||
};
|
||||
|
||||
let { marks, thumbStyle, axisStyle, onRailClick, onMarkClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<aside class="console-timeline" aria-label="Console timeline">
|
||||
<div class="timeline-axis" style={axisStyle}>
|
||||
<button
|
||||
type="button"
|
||||
class="timeline-rail"
|
||||
aria-label="Jump within console scrollback"
|
||||
onclick={onRailClick}
|
||||
>
|
||||
<span class="timeline-thumb" style={thumbStyle}></span>
|
||||
</button>
|
||||
<div class="timeline-marks" aria-label="Timeline marks">
|
||||
{#each marks as mark (mark.id)}
|
||||
<button
|
||||
type="button"
|
||||
class={`timeline-mark ${mark.kind}`}
|
||||
style={`top: ${mark.position}px`}
|
||||
aria-label={mark.detail ? `${mark.label}: ${mark.detail}` : mark.label}
|
||||
onclick={() => onMarkClick(mark)}
|
||||
>
|
||||
<span class="timeline-dot"></span>
|
||||
<span class="timeline-card">
|
||||
<span>{mark.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.console-timeline {
|
||||
--timeline-axis-padding: 3rem;
|
||||
|
||||
position: relative;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.timeline-axis {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.timeline-rail,
|
||||
.timeline-marks {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.timeline-rail {
|
||||
width: 0.35rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--line) 70%, transparent);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.timeline-thumb {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
min-height: 1.25rem;
|
||||
border-radius: inherit;
|
||||
background: var(--tui-cyan);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tui-cyan) 30%, transparent);
|
||||
}
|
||||
|
||||
.timeline-marks {
|
||||
width: 12rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-mark {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
display: flex;
|
||||
width: 11.5rem;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
pointer-events: auto;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--bg);
|
||||
border-radius: 999px;
|
||||
background: var(--tui-gray);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--line) 80%, transparent);
|
||||
}
|
||||
|
||||
.timeline-mark.turn .timeline-dot {
|
||||
background: var(--tui-blue);
|
||||
}
|
||||
|
||||
.timeline-mark.assistant .timeline-dot {
|
||||
background: var(--tui-green);
|
||||
}
|
||||
|
||||
.timeline-card {
|
||||
display: grid;
|
||||
width: 10rem;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-panel) 92%, transparent);
|
||||
box-shadow: var(--shadow-soft);
|
||||
opacity: 1;
|
||||
padding: 0.25rem 0.45rem;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.timeline-card span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-mark.turn .timeline-card {
|
||||
border-color: color-mix(in srgb, var(--tui-blue) 55%, var(--line));
|
||||
}
|
||||
|
||||
.timeline-mark.assistant .timeline-card {
|
||||
border-color: color-mix(in srgb, var(--tui-green) 55%, var(--line));
|
||||
}
|
||||
</style>
|
||||
@@ -37,3 +37,63 @@
|
||||
<p>{text}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rich-markdown {
|
||||
color: inherit;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
:global(.rich-markdown > :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:global(.rich-markdown > :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.rich-markdown p),
|
||||
:global(.rich-markdown ul),
|
||||
:global(.rich-markdown blockquote),
|
||||
:global(.rich-markdown pre) {
|
||||
margin: 0.45rem 0;
|
||||
}
|
||||
|
||||
:global(.rich-markdown ul) {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
:global(.rich-markdown h1),
|
||||
:global(.rich-markdown h2),
|
||||
:global(.rich-markdown h3),
|
||||
:global(.rich-markdown h4) {
|
||||
margin: 0.7rem 0 0.35rem;
|
||||
color: var(--text-strong);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
:global(.rich-markdown blockquote) {
|
||||
border-left: 2px solid var(--tui-dark-gray);
|
||||
color: var(--text-muted);
|
||||
padding-left: 0.8rem;
|
||||
}
|
||||
|
||||
:global(.rich-markdown :not(pre) > code) {
|
||||
background: color-mix(in oklch, var(--bg-raised) 80%, var(--tui-blue));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.35rem;
|
||||
color: var(--tui-blue);
|
||||
padding: 0.05rem 0.28rem;
|
||||
}
|
||||
|
||||
:global(.rich-markdown a) {
|
||||
color: var(--tui-cyan);
|
||||
}
|
||||
|
||||
:global(.rich-markdown .shiki) {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -94,7 +94,11 @@ export function workerConsolePath(
|
||||
);
|
||||
}
|
||||
|
||||
export type ConsoleEventInput = { eventId: string; event: ProtocolEvent };
|
||||
export type ConsoleEventInput = {
|
||||
eventId: string;
|
||||
event: ProtocolEvent;
|
||||
observedAtMs?: number;
|
||||
};
|
||||
|
||||
export function emptyConsoleProjection(): ConsoleProjection {
|
||||
return {
|
||||
|
||||
@@ -94,6 +94,33 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console exposes a foldable timeline beside the scroll body", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const consoleLine = await Deno.readTextFile(
|
||||
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||
);
|
||||
const consoleTimeline = await Deno.readTextFile(
|
||||
new URL("./ConsoleTimeline.svelte", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
consoleTimeline.includes('class="console-timeline"') &&
|
||||
consolePage.includes("timelineMarks") &&
|
||||
consolePage.includes("jumpToTimelineMark") &&
|
||||
consoleLine.includes("data-console-line-id={item.id}") &&
|
||||
consolePage.includes("class:timeline-open={timelineOpen}") &&
|
||||
consolePage.includes("class=\"timeline-fold\"") &&
|
||||
consolePage.includes("{#if timelineOpen}") &&
|
||||
consoleTimeline.includes(".timeline-thumb") &&
|
||||
consoleTimeline.includes(".timeline-card"),
|
||||
"Worker Console should expose a foldable timeline with scroll and line jump markers",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console composer fits to content without manual resize", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
@@ -101,13 +128,11 @@ Deno.test("Worker Console composer fits to content without manual resize", async
|
||||
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"),
|
||||
consolePage.includes(".console-composer textarea") &&
|
||||
consolePage.includes("resize: none") &&
|
||||
consolePage.includes("overflow-y: hidden"),
|
||||
"Console composer should autosize to content, cap at ten rows, and disable manual resize",
|
||||
);
|
||||
});
|
||||
@@ -221,9 +246,9 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
|
||||
);
|
||||
assert(
|
||||
consolePage.includes("workspaceApiPath(workspaceId, path)") &&
|
||||
consolePage.includes(
|
||||
"workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`)",
|
||||
),
|
||||
consolePage.includes("workerApiPath(") &&
|
||||
consolePage.includes("`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(") &&
|
||||
consolePage.includes("target.workerId"),
|
||||
"Worker detail should use the scoped backend Worker detail API",
|
||||
);
|
||||
assert(
|
||||
@@ -242,9 +267,8 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
|
||||
"reload token advancement should not synchronously read and write the rune state",
|
||||
);
|
||||
assert(
|
||||
consolePage.includes(
|
||||
"advanceReloadToken();\n void loadConsoleData(target);",
|
||||
) &&
|
||||
consolePage.includes("advanceReloadToken();") &&
|
||||
consolePage.includes("void loadConsoleData(target);") &&
|
||||
!consolePage.includes("void refreshConsole();\n });\n\n $effect"),
|
||||
"target-change effect should load data without depending on manual refresh state reads",
|
||||
);
|
||||
|
||||
+1322
-534
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user