chore: merge develop while preserving console interactions
This commit is contained in:
@@ -96,6 +96,7 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac
|
||||
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
|
||||
- `src/lib/workspace/header/`: Header frame, context, and overrides.
|
||||
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
|
||||
- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers.
|
||||
- `src/lib/workspace/ui/`: generic presentation components such as `Tooltip`, `Bevel`, and `BevelLine`.
|
||||
- `src/routes/design-lab/workspace-web-ux/`: static Design Language showroom.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --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,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -149,6 +149,33 @@ entry_id: string,
|
||||
*/
|
||||
timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<string>, } & ({ "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, });
|
||||
|
||||
export type SessionConversationTurn = { turn_id: string, entries: Array<SessionSnapshotEntry>, };
|
||||
|
||||
export type SessionHistoryLineageBoundary = { lineage_id: string,
|
||||
/**
|
||||
* Canonical public form of the final user-visible turn at the exact
|
||||
* provider boundary. Entries after that boundary are excluded.
|
||||
*/
|
||||
adopted_through_turn?: SessionConversationTurn | null, };
|
||||
|
||||
export type SessionHistoryPage = { session_id: string,
|
||||
/**
|
||||
* Stable identity of the adopted active lineage used to fence stale pages.
|
||||
*/
|
||||
lineage_id: string,
|
||||
/**
|
||||
* Older lineage identities that the active lineage fully adopted through
|
||||
* one or more consecutive Compact rotations. Clients may retain already
|
||||
* loaded turns when their prior lineage appears here, while replacing the
|
||||
* cursor with this page's boundary.
|
||||
*/
|
||||
compact_ancestor_lineage_ids?: Array<string>,
|
||||
/**
|
||||
* Immediate parent relationship used to retain only the adopted prefix
|
||||
* across fork/rewind refreshes that have no newest-page overlap.
|
||||
*/
|
||||
parent_lineage?: SessionHistoryLineageBoundary | null, turns: Array<SessionConversationTurn>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
export type PendingSubmissionSummary = { submission_id: string,
|
||||
/**
|
||||
* Single-line display text, at most 240 Unicode scalars plus an ellipsis.
|
||||
@@ -306,7 +333,11 @@ export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "req
|
||||
|
||||
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
|
||||
|
||||
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
|
||||
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": {
|
||||
/**
|
||||
* Stable durable history identity. Older mixed-version producers omit it.
|
||||
*/
|
||||
entry_id?: string | null, segments: Array<Segment>, } } | { "event": "session_entry_committed", "data": { entry: SessionSnapshotEntry, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
|
||||
/**
|
||||
* The attempt that just failed. 1 origin.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -182,7 +182,9 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
|
||||
);
|
||||
const kind = stringField(source, "kind");
|
||||
if (kind === "repository") {
|
||||
if (source.grant_id !== undefined || source.grant_permissions !== undefined) {
|
||||
if (
|
||||
source.grant_id !== undefined || source.grant_permissions !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"Repository Workdir source must not contain External grant authority",
|
||||
);
|
||||
@@ -201,7 +203,7 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
|
||||
return {
|
||||
kind,
|
||||
grant_id: stringField(source, "grant_id"),
|
||||
grant_permissions: parseExternalWorkdirPermissions(
|
||||
grant_permissions: parseExternalWorkdirGrantPermissions(
|
||||
source.grant_permissions,
|
||||
"External Workdir grant permissions",
|
||||
),
|
||||
@@ -226,6 +228,21 @@ export function parseExternalWorkdirPermissions(
|
||||
};
|
||||
}
|
||||
|
||||
export function parseExternalWorkdirGrantPermissions(
|
||||
value: unknown,
|
||||
label = "External Workdir grant permissions",
|
||||
): ExternalWorkdirPermissions {
|
||||
const permissions = parseExternalWorkdirPermissions(value, label);
|
||||
const hierarchical = permissions.read &&
|
||||
(!permissions.command || permissions.write);
|
||||
if (!hierarchical) {
|
||||
throw new Error(
|
||||
`${label} must be READ=(true,false,false), WRITE=(true,true,false), or COMMAND=(true,true,true)`,
|
||||
);
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
|
||||
const record = exactRecord(
|
||||
value,
|
||||
|
||||
@@ -106,7 +106,7 @@ Deno.test("Worker summary parser preserves attachment-effective External permiss
|
||||
source: {
|
||||
kind: "external_grant",
|
||||
grant_id: "grant-1",
|
||||
grant_permissions: { read: true, write: false, command: true },
|
||||
grant_permissions: { read: true, write: true, command: true },
|
||||
},
|
||||
materializer_kind: "client_hosted_external",
|
||||
status: "active",
|
||||
|
||||
@@ -4,15 +4,15 @@ import { cleanup, fireEvent, render } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import ConsoleTurnNavigation from "./ConsoleTurnNavigation.svelte";
|
||||
|
||||
const turns = [
|
||||
{ id: "u1", user: "First question", assistant: "First answer" },
|
||||
{ id: "u2", user: "Second question", assistant: "" },
|
||||
const items = [
|
||||
{ turnId: "u1", lineId: "u1", user: "First question", assistant: ["First answer"] },
|
||||
{ turnId: "u2", lineId: "u2", user: "Second question", assistant: [] },
|
||||
];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("renders one bar per turn and previews both roles on hover", async () => {
|
||||
const view = render(ConsoleTurnNavigation, { turns, onSelect: vi.fn() });
|
||||
const view = render(ConsoleTurnNavigation, { items, onTurnClick: vi.fn() });
|
||||
expect(view.getAllByRole("button")).toHaveLength(2);
|
||||
expect(view.queryByRole("tooltip")).toBeNull();
|
||||
const button = view.getAllByRole("button")[0];
|
||||
@@ -25,14 +25,14 @@ test("renders one bar per turn and previews both roles on hover", async () => {
|
||||
expect(view.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
test("focus previews unanswered turns, click selects the user anchor, Escape dismisses", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const view = render(ConsoleTurnNavigation, { turns, onSelect });
|
||||
test("focus previews unanswered items, click selects the user anchor, Escape dismisses", async () => {
|
||||
const onTurnClick = vi.fn();
|
||||
const view = render(ConsoleTurnNavigation, { items, onTurnClick });
|
||||
const button = view.getAllByRole("button")[1];
|
||||
await fireEvent.focus(button);
|
||||
expect(view.getByRole("tooltip").textContent).toContain("No response yet");
|
||||
await fireEvent.click(button);
|
||||
expect(onSelect).toHaveBeenCalledWith("u2");
|
||||
expect(onTurnClick).toHaveBeenCalledWith(items[1]);
|
||||
await fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(view.queryByRole("tooltip")).toBeNull();
|
||||
await fireEvent.blur(button);
|
||||
@@ -43,21 +43,21 @@ test("focus previews unanswered turns, click selects the user anchor, Escape dis
|
||||
});
|
||||
|
||||
test("updates the visible response in place and removes stale previews on history replacement", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const view = render(ConsoleTurnNavigation, { turns, onSelect });
|
||||
const onTurnClick = vi.fn();
|
||||
const view = render(ConsoleTurnNavigation, { items, onTurnClick });
|
||||
await fireEvent.mouseEnter(view.getAllByRole("button")[1]);
|
||||
await view.rerender({ turns: [turns[0], { ...turns[1], assistant: "Streaming answer" }], onSelect });
|
||||
await view.rerender({ items: [items[0], { ...items[1], assistant: ["Streaming answer"] }], onTurnClick });
|
||||
expect(view.getByRole("tooltip").textContent).toContain("Streaming answer");
|
||||
expect(view.getAllByRole("button")).toHaveLength(2);
|
||||
await view.rerender({ turns: [], onSelect });
|
||||
await view.rerender({ items: [], onTurnClick });
|
||||
expect(view.queryByRole("tooltip")).toBeNull();
|
||||
expect(view.queryAllByRole("button")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("preview content stays text and scrolling the rail dismisses its old position", async () => {
|
||||
const view = render(ConsoleTurnNavigation, {
|
||||
turns: [{ id: "u", user: "<img src=x onerror=alert(1)>", assistant: "<script>bad()</script>" }],
|
||||
onSelect: vi.fn(),
|
||||
items: [{ turnId: "u", lineId: "u", user: "<img src=x onerror=alert(1)>", assistant: ["<script>bad()</script>"] }],
|
||||
onTurnClick: vi.fn(),
|
||||
});
|
||||
await fireEvent.mouseEnter(view.getByRole("button"));
|
||||
const tooltip = view.getByRole("tooltip");
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { ConsoleTurn } from './turn-navigation';
|
||||
import type { TurnNavigationItem } from './turn-navigation';
|
||||
|
||||
let { turns, onSelect }: {
|
||||
turns: ConsoleTurn[];
|
||||
onSelect: (id: string) => void;
|
||||
let {
|
||||
items, onTurnClick, hasMore = false, loading = false, error = null,
|
||||
onLoadMore = () => {}, onTopEdgeChange = () => {}, historyAvailable = true,
|
||||
element = $bindable(null),
|
||||
}: {
|
||||
items: TurnNavigationItem[];
|
||||
onTurnClick: (item: TurnNavigationItem) => void;
|
||||
hasMore?: boolean;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onLoadMore?: () => void;
|
||||
onTopEdgeChange?: (atTop: boolean) => void;
|
||||
historyAvailable?: boolean;
|
||||
element?: HTMLElement | null;
|
||||
} = $props();
|
||||
|
||||
const tooltipId = $props.id();
|
||||
@@ -14,8 +25,8 @@
|
||||
let railHeight = $state(0);
|
||||
let previewHeight = $state(0);
|
||||
let rail: HTMLElement;
|
||||
const preview = $derived(dismissed ? undefined : turns.find(
|
||||
(turn) => turn.id === (hoveredId ?? focusedId),
|
||||
const preview = $derived(dismissed ? undefined : items.find(
|
||||
(turn) => turn.turnId === (hoveredId ?? focusedId),
|
||||
));
|
||||
const previewTop = $derived(Math.max(0, Math.min(anchorY, railHeight - previewHeight)));
|
||||
|
||||
@@ -37,18 +48,35 @@
|
||||
bind:clientHeight={railHeight}
|
||||
onmouseleave={() => { hoveredId = null; }}
|
||||
>
|
||||
<div class="turn-bars" onscroll={() => { dismissed = true; }}>
|
||||
{#each turns as turn, index (turn.id)}
|
||||
<div class="turn-bars turn-list" bind:this={element} onscroll={(event) => {
|
||||
dismissed = true;
|
||||
onTopEdgeChange(event.currentTarget.scrollTop <= 1);
|
||||
}}>
|
||||
<div class="history-boundary">
|
||||
{#if !historyAvailable}
|
||||
<span title="Earlier history is unavailable for this view" aria-label="Earlier history is unavailable for this view">·</span>
|
||||
{:else if loading}
|
||||
<button type="button" disabled aria-label="Loading earlier conversation…">…</button>
|
||||
{:else if error}
|
||||
<button type="button" onclick={onLoadMore} title={error} aria-label="Retry earlier conversation">↻</button>
|
||||
{:else if hasMore}
|
||||
<button type="button" onclick={onLoadMore} title="Earlier conversation available" aria-label="Earlier conversation available">↑</button>
|
||||
{:else}
|
||||
<span title="Start of conversation" aria-label="Start of conversation">·</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#each items as turn, index (turn.turnId)}
|
||||
<button
|
||||
type="button"
|
||||
class="turn-button"
|
||||
class:previewing={preview?.id === turn.id}
|
||||
data-turn-id={turn.turnId}
|
||||
class:previewing={preview?.turnId === turn.turnId}
|
||||
aria-label={`Turn ${index + 1}: ${turn.user || 'User message'}`}
|
||||
aria-describedby={preview?.id === turn.id ? tooltipId : undefined}
|
||||
onmouseenter={(event) => show(event, turn.id)}
|
||||
onfocus={(event) => show(event, turn.id)}
|
||||
aria-describedby={preview?.turnId === turn.turnId ? tooltipId : undefined}
|
||||
onmouseenter={(event) => show(event, turn.turnId)}
|
||||
onfocus={(event) => show(event, turn.turnId)}
|
||||
onblur={() => { focusedId = null; }}
|
||||
onclick={() => onSelect(turn.id)}
|
||||
onclick={() => onTurnClick(turn)}
|
||||
><span class="turn-bar" aria-hidden="true"></span></button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -60,7 +88,7 @@
|
||||
>
|
||||
<div class="turn-preview" id={tooltipId} role="tooltip">
|
||||
<p class="preview-user">{preview.user || '—'}</p>
|
||||
<p class="preview-assistant">{preview.assistant || 'No response yet'}</p>
|
||||
<p class="preview-assistant">{preview.assistant.join(" ") || 'No response yet'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -86,7 +114,19 @@
|
||||
.turn-bars::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Center short lists without making overflowing turns unreachable. */
|
||||
.turn-button:first-child { margin-top: auto; }
|
||||
.history-boundary { margin-top: auto; }
|
||||
.history-boundary button, .history-boundary span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
min-height: 24px;
|
||||
padding: var(--space-1);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
}
|
||||
.history-boundary button { cursor: pointer; color: var(--tui-cyan); }
|
||||
.turn-button:last-child { margin-bottom: auto; }
|
||||
|
||||
.turn-button {
|
||||
|
||||
@@ -20,13 +20,9 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import SvelteMarkdown, {
|
||||
buildUnsupportedHTML,
|
||||
type Renderers,
|
||||
type SvelteMarkdownProps,
|
||||
} from "@humanspeak/svelte-markdown";
|
||||
import type { Renderers } from "@humanspeak/svelte-markdown";
|
||||
import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki";
|
||||
import MarkdownLink from "$lib/workspace/console/MarkdownLink.svelte";
|
||||
import SafeMarkdown from "$lib/workspace/markdown/SafeMarkdown.svelte";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
@@ -36,24 +32,15 @@
|
||||
|
||||
let { text, streamId = "static", class: className = "" }: Props = $props();
|
||||
|
||||
const options = {
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
|
||||
|
||||
const renderers = {
|
||||
code: ShikiCode,
|
||||
html: buildUnsupportedHTML(),
|
||||
link: MarkdownLink,
|
||||
} satisfies Partial<Renderers>;
|
||||
</script>
|
||||
|
||||
<div class={`rich-markdown ${className}`}>
|
||||
<SvelteMarkdown source={text} {streamId} {options} {renderers} streaming />
|
||||
</div>
|
||||
<SafeMarkdown {text} {streamId} class={`rich-markdown ${className}`} {renderers} streaming />
|
||||
|
||||
<style>
|
||||
.rich-markdown {
|
||||
:global(.rich-markdown) {
|
||||
color: inherit;
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import type {
|
||||
SessionConversationTurn,
|
||||
SessionHistoryPage,
|
||||
SessionSnapshotEntry,
|
||||
} from "$lib/generated/protocol";
|
||||
import {
|
||||
applyConsoleHistoryPage,
|
||||
beginConsoleHistoryRequest,
|
||||
type ConsoleHistoryState,
|
||||
conversationTurnPreviews,
|
||||
conversationTurnPreviewsFromLines,
|
||||
emptyConsoleHistoryState,
|
||||
historyEntries,
|
||||
setConsoleHistoryTopEdge,
|
||||
shouldLoadHistoryAtTop,
|
||||
} from "./history";
|
||||
|
||||
declare const Deno: { test(name: string, fn: () => void): void };
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: "user" | "assistant",
|
||||
text: string,
|
||||
): SessionSnapshotEntry {
|
||||
return {
|
||||
entry_id: id,
|
||||
timestamp: 1,
|
||||
provenance: role === "user" ? "human_input" : "model_output",
|
||||
kind: "message",
|
||||
role,
|
||||
content: [{ kind: "text", text }],
|
||||
};
|
||||
}
|
||||
|
||||
function turn(index: number): SessionConversationTurn {
|
||||
return {
|
||||
turn_id: `u-${index}`,
|
||||
entries: [
|
||||
message(`u-${index}`, "user", `question ${index}\nignored`),
|
||||
message(`a-progress-${index}`, "assistant", `working ${index}`),
|
||||
message(
|
||||
`a-final-${index}`,
|
||||
"assistant",
|
||||
`done ${index}\ndetail a\ndetail b\ndetail c`,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function page(
|
||||
turns: SessionConversationTurn[],
|
||||
cursor: string | null,
|
||||
lineageId = "lineage-a",
|
||||
compactAncestorLineageIds: string[] = [],
|
||||
parentLineage?: {
|
||||
lineage_id: string;
|
||||
adopted_through_turn?: SessionConversationTurn | null;
|
||||
},
|
||||
): SessionHistoryPage {
|
||||
return {
|
||||
session_id: "session-a",
|
||||
lineage_id: lineageId,
|
||||
compact_ancestor_lineage_ids: compactAncestorLineageIds,
|
||||
parent_lineage: parentLineage,
|
||||
turns,
|
||||
next_cursor: cursor,
|
||||
has_more: cursor !== null,
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("history prepends 12 turns as 5 then 5 then 2 without duplicates", () => {
|
||||
let state = emptyConsoleHistoryState();
|
||||
state = beginConsoleHistoryRequest(state, null)!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
|
||||
null,
|
||||
);
|
||||
state = beginConsoleHistoryRequest(state, "cursor-8")!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([3, 4, 5, 6, 7].map(turn), "cursor-3"),
|
||||
"cursor-8",
|
||||
);
|
||||
state = beginConsoleHistoryRequest(state, "cursor-3")!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([1, 2].map(turn), null),
|
||||
"cursor-3",
|
||||
);
|
||||
assert(state.turns.length === 12, "all real turns must be retained");
|
||||
assert(state.turns[0].turn_id === "u-1", "pages must remain chronological");
|
||||
assert(
|
||||
!state.hasMore && state.cursor === null,
|
||||
"final short page must end history",
|
||||
);
|
||||
|
||||
state = { ...state, requestedCursor: "__initial__", status: "loading" };
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
|
||||
null,
|
||||
);
|
||||
assert(
|
||||
state.turns.length === 12,
|
||||
"refresh must dedupe stable turn identities",
|
||||
);
|
||||
assert(
|
||||
historyEntries(state).length === 36,
|
||||
"entries must dedupe by stable identity",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("refresh replaces overlapping latest turns while retaining loaded earlier turns", () => {
|
||||
let state = emptyConsoleHistoryState();
|
||||
state = beginConsoleHistoryRequest(state, null)!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([8, 9, 10, 11, 12].map(turn), "cursor-8"),
|
||||
null,
|
||||
);
|
||||
state = beginConsoleHistoryRequest(state, "cursor-8")!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([3, 4, 5, 6, 7].map(turn), "cursor-3"),
|
||||
"cursor-8",
|
||||
);
|
||||
|
||||
const updated = turn(12);
|
||||
updated.entries.push(message("a-later-12", "assistant", "actually finished"));
|
||||
state = beginConsoleHistoryRequest(state, null)!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([8, 9, 10, 11].map(turn).concat(updated), "stale-cursor-is-ignored"),
|
||||
null,
|
||||
);
|
||||
|
||||
assert(
|
||||
state.turns.length === 10,
|
||||
"refresh must not discard loaded earlier turns",
|
||||
);
|
||||
assert(
|
||||
state.turns.at(-1)?.entries.at(-1)?.entry_id === "a-later-12",
|
||||
"refresh must replace an overlapping turn with its latest committed entries",
|
||||
);
|
||||
assert(
|
||||
state.cursor === "cursor-3",
|
||||
"refresh must retain the oldest paging boundary",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("same-lineage refresh without overlap replaces the paging boundary", () => {
|
||||
let state: ConsoleHistoryState = {
|
||||
...emptyConsoleHistoryState(),
|
||||
sessionId: "session-a",
|
||||
lineageId: "lineage-a",
|
||||
turns: [1, 2, 3, 4].map(turn),
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
status: "loading",
|
||||
requestedCursor: "__initial__",
|
||||
};
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([6, 7, 8, 9, 10].map(turn), "cursor-6", "lineage-a"),
|
||||
null,
|
||||
);
|
||||
|
||||
assert(
|
||||
state.turns.map((value) => value.turn_id).join(",") ===
|
||||
"u-1,u-2,u-3,u-4,u-6,u-7,u-8,u-9,u-10",
|
||||
"a reconnect may preserve loaded rows while still exposing the missing gap",
|
||||
);
|
||||
assert(
|
||||
state.cursor === "cursor-6" && state.hasMore,
|
||||
"a non-overlapping same-lineage page must replace an exhausted old boundary",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("changed lineage keeps the adopted prefix and discards the replaced branch", () => {
|
||||
let state = emptyConsoleHistoryState();
|
||||
state = beginConsoleHistoryRequest(state, null)!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([6, 7, 8, 9, 10].map(turn), "cursor-6"),
|
||||
null,
|
||||
);
|
||||
state = beginConsoleHistoryRequest(state, "cursor-6")!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([1, 2, 3, 4, 5].map(turn), null),
|
||||
"cursor-6",
|
||||
);
|
||||
|
||||
state = beginConsoleHistoryRequest(state, null)!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([3, 4, 5, 11, 12].map(turn), "cursor-3-new", "lineage-b"),
|
||||
null,
|
||||
);
|
||||
|
||||
assert(
|
||||
state.turns.map((value) => value.turn_id).join(",") ===
|
||||
"u-1,u-2,u-3,u-4,u-5,u-11,u-12",
|
||||
"rewind must preserve only the prefix adopted by the new lineage",
|
||||
);
|
||||
assert(
|
||||
state.cursor === "cursor-3-new" && state.hasMore,
|
||||
"a changed lineage must replace the stale paging cursor",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Compact lineage refresh preserves already loaded adopted history", () => {
|
||||
let state: ConsoleHistoryState = {
|
||||
...emptyConsoleHistoryState(),
|
||||
sessionId: "session-a",
|
||||
lineageId: "lineage-a",
|
||||
turns: Array.from({ length: 10 }, (_, index) => turn(index + 1)),
|
||||
status: "loading",
|
||||
requestedCursor: "__initial__",
|
||||
};
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page(
|
||||
[11, 12, 13, 14, 15].map(turn),
|
||||
"cursor-11-new",
|
||||
"lineage-compact",
|
||||
["lineage-a"],
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
assert(
|
||||
state.turns.map((value) => value.turn_id).join(",") ===
|
||||
Array.from({ length: 15 }, (_, index) => `u-${index + 1}`).join(","),
|
||||
"Compact must keep the adopted prefix without requiring newest-page overlap",
|
||||
);
|
||||
assert(
|
||||
state.cursor === "cursor-11-new" && state.hasMore,
|
||||
"Compact must replace the old-lineage cursor even when adopted turns are preserved",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("fork lineage boundary preserves an adopted prefix without page overlap", () => {
|
||||
let state: ConsoleHistoryState = {
|
||||
...emptyConsoleHistoryState(),
|
||||
sessionId: "session-a",
|
||||
lineageId: "lineage-a",
|
||||
turns: Array.from({ length: 50 }, (_, index) => turn(index + 1)),
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
status: "loading",
|
||||
requestedCursor: "__initial__",
|
||||
};
|
||||
state.turns[24]!.entries.push(
|
||||
message("a-unadopted-25", "assistant", "stale sibling response"),
|
||||
);
|
||||
const adoptedBoundary = turn(25);
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page(
|
||||
[101, 102, 103, 104, 105].map(turn),
|
||||
"cursor-101",
|
||||
"lineage-fork",
|
||||
[],
|
||||
{
|
||||
lineage_id: "lineage-a",
|
||||
adopted_through_turn: adoptedBoundary,
|
||||
},
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
assert(
|
||||
state.turns.map((value) => value.turn_id).join(",") ===
|
||||
[
|
||||
...Array.from({ length: 25 }, (_, index) => `u-${index + 1}`),
|
||||
...Array.from({ length: 5 }, (_, index) => `u-${index + 101}`),
|
||||
].join(","),
|
||||
"fork refresh must retain only the authoritative adopted prefix",
|
||||
);
|
||||
assert(
|
||||
historyEntries(state).every((entry) => entry.entry_id !== "a-unadopted-25"),
|
||||
"the canonical provider boundary must trim a sibling entry in the same visible turn",
|
||||
);
|
||||
assert(
|
||||
state.cursor === "cursor-101" && state.hasMore,
|
||||
"fork refresh must replace the old paging boundary",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("changed lineage without a stable overlap replaces retained turns", () => {
|
||||
let state: ConsoleHistoryState = {
|
||||
...emptyConsoleHistoryState(),
|
||||
sessionId: "session-a",
|
||||
lineageId: "lineage-a",
|
||||
turns: [1, 2, 3].map(turn),
|
||||
cursor: "old-cursor",
|
||||
hasMore: true,
|
||||
status: "loading",
|
||||
requestedCursor: "__initial__",
|
||||
};
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([11, 12].map(turn), null, "lineage-b"),
|
||||
null,
|
||||
);
|
||||
assert(
|
||||
state.turns.map((value) => value.turn_id).join(",") === "u-11,u-12",
|
||||
"unrelated lineage content must not survive refresh",
|
||||
);
|
||||
assert(
|
||||
state.cursor === null && !state.hasMore,
|
||||
"new lineage boundary must be authoritative",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("line previews include a current turn outside the retained five-turn page", () => {
|
||||
const lines = [1, 2, 3, 4, 5, 6].flatMap((index) => [
|
||||
{
|
||||
id: `user-${index}`,
|
||||
entryId: `u-${index}`,
|
||||
kind: "user" as const,
|
||||
title: "User",
|
||||
body: `question ${index}`,
|
||||
source: "event" as const,
|
||||
},
|
||||
{
|
||||
id: `assistant-${index}`,
|
||||
entryId: `a-${index}`,
|
||||
kind: "assistant" as const,
|
||||
title: "Assistant",
|
||||
body: `done ${index}`,
|
||||
source: "event" as const,
|
||||
},
|
||||
]);
|
||||
assert(
|
||||
conversationTurnPreviewsFromLines(lines).map((preview) => preview.turnId)
|
||||
.join(",") ===
|
||||
"u-1,u-2,u-3,u-4,u-5,u-6",
|
||||
"turn navigation must include unmatched current snapshot turns in transcript order",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("turn preview uses one user line and final non-empty assistant up to three lines", () => {
|
||||
const preview = conversationTurnPreviews([turn(4)])[0];
|
||||
assert(preview.user === "question 4", "user preview must use its first line");
|
||||
assert(
|
||||
preview.assistant.join("|") === "done 4|detail a|detail b",
|
||||
"preview must choose the final assistant and cap it at three lines",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("top sentinel requests only once until it leaves the edge", () => {
|
||||
let state: ConsoleHistoryState = {
|
||||
...emptyConsoleHistoryState(),
|
||||
status: "ready",
|
||||
cursor: "older",
|
||||
hasMore: true,
|
||||
};
|
||||
assert(
|
||||
shouldLoadHistoryAtTop(state, true),
|
||||
"armed top edge should request a page",
|
||||
);
|
||||
state = beginConsoleHistoryRequest(state, "older")!;
|
||||
assert(
|
||||
!shouldLoadHistoryAtTop(state, true),
|
||||
"in-flight page must suppress duplicates",
|
||||
);
|
||||
state = {
|
||||
...state,
|
||||
status: "ready",
|
||||
requestedCursor: null,
|
||||
};
|
||||
assert(
|
||||
!shouldLoadHistoryAtTop(state, true),
|
||||
"stationary sentinel must not drain history",
|
||||
);
|
||||
state = setConsoleHistoryTopEdge(state, false);
|
||||
assert(
|
||||
shouldLoadHistoryAtTop(state, true),
|
||||
"leaving the top must rearm one request",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { ConsoleLine } from "./model.ts";
|
||||
import type {
|
||||
SessionConversationTurn,
|
||||
SessionHistoryPage,
|
||||
SessionSnapshotEntry,
|
||||
} from "$lib/generated/protocol";
|
||||
|
||||
export const CONSOLE_HISTORY_PAGE_TURNS = 5;
|
||||
|
||||
export type ConsoleHistoryStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export type ConsoleHistoryState = {
|
||||
sessionId: string | null;
|
||||
lineageId: string | null;
|
||||
turns: SessionConversationTurn[];
|
||||
cursor: string | null;
|
||||
hasMore: boolean;
|
||||
status: ConsoleHistoryStatus;
|
||||
error: string | null;
|
||||
/** Cursor currently in flight; prevents duplicate sentinel requests. */
|
||||
requestedCursor: string | null;
|
||||
/** Rearmed only after leaving the top edge or an explicit user action. */
|
||||
topEdgeArmed: boolean;
|
||||
};
|
||||
|
||||
export type ConsoleTurnPreview = {
|
||||
turnId: string;
|
||||
lineId: string;
|
||||
user: string;
|
||||
assistant: string[];
|
||||
};
|
||||
|
||||
export function emptyConsoleHistoryState(): ConsoleHistoryState {
|
||||
return {
|
||||
sessionId: null,
|
||||
lineageId: null,
|
||||
turns: [],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
status: "idle",
|
||||
error: null,
|
||||
requestedCursor: null,
|
||||
topEdgeArmed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function beginConsoleHistoryRequest(
|
||||
state: ConsoleHistoryState,
|
||||
cursor: string | null,
|
||||
): ConsoleHistoryState | null {
|
||||
if (state.status === "loading" || state.requestedCursor !== null) return null;
|
||||
if (cursor !== null && (!state.hasMore || cursor !== state.cursor)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
status: "loading",
|
||||
error: null,
|
||||
requestedCursor: cursor ?? "__initial__",
|
||||
topEdgeArmed: cursor === null ? state.topEdgeArmed : false,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyConsoleHistoryPage(
|
||||
state: ConsoleHistoryState,
|
||||
page: SessionHistoryPage,
|
||||
requestedCursor: string | null,
|
||||
): ConsoleHistoryState {
|
||||
const requestKey = requestedCursor ?? "__initial__";
|
||||
if (state.requestedCursor !== requestKey) return state;
|
||||
|
||||
const initial = requestedCursor === null;
|
||||
if (
|
||||
!initial &&
|
||||
(state.sessionId !== page.session_id || state.lineageId !== page.lineage_id)
|
||||
) {
|
||||
return failConsoleHistoryRequest(
|
||||
state,
|
||||
"The conversation changed while earlier history was loading. Retry from the current view.",
|
||||
);
|
||||
}
|
||||
|
||||
const incoming = page.turns.filter((turn) => turn.entries.length > 0);
|
||||
const refreshingSameSession = initial &&
|
||||
state.sessionId === page.session_id &&
|
||||
state.turns.length > 0;
|
||||
const refreshingSameLineage = refreshingSameSession &&
|
||||
state.lineageId === page.lineage_id;
|
||||
const refreshingParentLineage = refreshingSameSession &&
|
||||
state.lineageId !== null &&
|
||||
page.parent_lineage?.lineage_id === state.lineageId;
|
||||
const refreshingCompactDescendant = refreshingSameSession &&
|
||||
state.lineageId !== null &&
|
||||
(page.compact_ancestor_lineage_ids ?? []).includes(state.lineageId);
|
||||
const refreshBridgesRetained = refreshingSameLineage && incoming.some(
|
||||
(turn) => state.turns.some((retained) => retained.turn_id === turn.turn_id),
|
||||
);
|
||||
const turns = initial
|
||||
? refreshingSameLineage
|
||||
? mergeRefreshedTurns(state.turns, incoming)
|
||||
: refreshingParentLineage
|
||||
? reconcileParentLineage(
|
||||
state.turns,
|
||||
incoming,
|
||||
page.parent_lineage?.adopted_through_turn ?? null,
|
||||
)
|
||||
: refreshingCompactDescendant
|
||||
? mergeRefreshedTurns(state.turns, incoming)
|
||||
: refreshingSameSession
|
||||
? reconcileChangedLineage(state.turns, incoming)
|
||||
: dedupeTurns(incoming)
|
||||
: dedupeTurns([...incoming, ...state.turns]);
|
||||
return {
|
||||
...state,
|
||||
sessionId: page.session_id,
|
||||
lineageId: page.lineage_id,
|
||||
turns,
|
||||
cursor: refreshBridgesRetained ? state.cursor : (page.next_cursor ?? null),
|
||||
hasMore: refreshBridgesRetained ? state.hasMore : page.has_more,
|
||||
status: "ready",
|
||||
error: null,
|
||||
requestedCursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function failConsoleHistoryRequest(
|
||||
state: ConsoleHistoryState,
|
||||
message: string,
|
||||
): ConsoleHistoryState {
|
||||
return {
|
||||
...state,
|
||||
status: "error",
|
||||
error: message.slice(0, 512),
|
||||
requestedCursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function setConsoleHistoryTopEdge(
|
||||
state: ConsoleHistoryState,
|
||||
atTop: boolean,
|
||||
): ConsoleHistoryState {
|
||||
if (!atTop && !state.topEdgeArmed) return { ...state, topEdgeArmed: true };
|
||||
return state;
|
||||
}
|
||||
|
||||
export function shouldLoadHistoryAtTop(
|
||||
state: ConsoleHistoryState,
|
||||
atTop: boolean,
|
||||
): boolean {
|
||||
return atTop && state.topEdgeArmed && state.hasMore &&
|
||||
state.status !== "loading" && state.requestedCursor === null;
|
||||
}
|
||||
|
||||
export function historyEntries(
|
||||
state: ConsoleHistoryState,
|
||||
): SessionSnapshotEntry[] {
|
||||
return state.turns.flatMap((turn) => turn.entries);
|
||||
}
|
||||
|
||||
export function conversationTurnPreviews(
|
||||
turns: readonly SessionConversationTurn[],
|
||||
): ConsoleTurnPreview[] {
|
||||
return turns.map((turn) => {
|
||||
const userEntry = turn.entries.find(isUserEntry);
|
||||
const assistants = turn.entries
|
||||
.filter(isAssistantEntry)
|
||||
.map(entryText)
|
||||
.filter((text) => text.trim().length > 0);
|
||||
return {
|
||||
turnId: turn.turn_id,
|
||||
lineId: userEntry?.entry_id ?? turn.turn_id,
|
||||
user: firstNonEmptyLine(userEntry ? entryText(userEntry) : ""),
|
||||
assistant: previewAssistant(assistants.at(-1) ?? ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function conversationTurnPreviewsFromLines(
|
||||
lines: readonly ConsoleLine[],
|
||||
): ConsoleTurnPreview[] {
|
||||
const previews: ConsoleTurnPreview[] = [];
|
||||
let current: {
|
||||
turnId: string;
|
||||
lineId: string;
|
||||
user: string;
|
||||
assistants: string[];
|
||||
} | null = null;
|
||||
for (const line of lines) {
|
||||
if (line.kind === "user") {
|
||||
if (current) {
|
||||
previews.push({
|
||||
turnId: current.turnId,
|
||||
lineId: current.lineId,
|
||||
user: current.user,
|
||||
assistant: previewAssistant(current.assistants.at(-1) ?? ""),
|
||||
});
|
||||
}
|
||||
const stableId = line.entryId ?? line.id;
|
||||
current = {
|
||||
turnId: stableId,
|
||||
lineId: stableId,
|
||||
user: firstNonEmptyLine(line.body),
|
||||
assistants: [],
|
||||
};
|
||||
} else if (
|
||||
(line.kind === "assistant" ||
|
||||
(line.kind === "in_flight" && line.title === "in-flight assistant text")) &&
|
||||
current && line.body.trim().length > 0
|
||||
) {
|
||||
current.assistants.push(line.body);
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
previews.push({
|
||||
turnId: current.turnId,
|
||||
lineId: current.lineId,
|
||||
user: current.user,
|
||||
assistant: previewAssistant(current.assistants.at(-1) ?? ""),
|
||||
});
|
||||
}
|
||||
return previews;
|
||||
}
|
||||
|
||||
function previewAssistant(value: string): string[] {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function reconcileParentLineage(
|
||||
retained: readonly SessionConversationTurn[],
|
||||
refreshed: readonly SessionConversationTurn[],
|
||||
adoptedThroughTurn: SessionConversationTurn | null,
|
||||
): SessionConversationTurn[] {
|
||||
if (adoptedThroughTurn === null) return dedupeTurns(refreshed);
|
||||
const boundary = retained.findIndex((turn) =>
|
||||
turn.turn_id === adoptedThroughTurn.turn_id
|
||||
);
|
||||
if (boundary < 0) return dedupeTurns(refreshed);
|
||||
const refreshedIncludesBoundary = refreshed.some((turn) =>
|
||||
turn.turn_id === adoptedThroughTurn.turn_id
|
||||
);
|
||||
return dedupeTurns([
|
||||
...retained.slice(0, boundary),
|
||||
...(refreshedIncludesBoundary ? [] : [adoptedThroughTurn]),
|
||||
...refreshed,
|
||||
]);
|
||||
}
|
||||
|
||||
function reconcileChangedLineage(
|
||||
retained: readonly SessionConversationTurn[],
|
||||
refreshed: readonly SessionConversationTurn[],
|
||||
): SessionConversationTurn[] {
|
||||
const retainedPositions = new Map(
|
||||
retained.map((turn, index) => [turn.turn_id, index] as const),
|
||||
);
|
||||
const firstShared = refreshed.find((turn) =>
|
||||
retainedPositions.has(turn.turn_id)
|
||||
);
|
||||
if (!firstShared) return dedupeTurns(refreshed);
|
||||
|
||||
const retainedPrefix = retained.slice(
|
||||
0,
|
||||
retainedPositions.get(firstShared.turn_id) ?? 0,
|
||||
);
|
||||
return dedupeTurns([...retainedPrefix, ...refreshed]);
|
||||
}
|
||||
|
||||
function mergeRefreshedTurns(
|
||||
retained: readonly SessionConversationTurn[],
|
||||
refreshed: readonly SessionConversationTurn[],
|
||||
): SessionConversationTurn[] {
|
||||
const refreshedIds = new Set(refreshed.map((turn) => turn.turn_id));
|
||||
return dedupeTurns([
|
||||
...retained.filter((turn) => !refreshedIds.has(turn.turn_id)),
|
||||
...refreshed,
|
||||
]);
|
||||
}
|
||||
|
||||
function dedupeTurns(
|
||||
turns: readonly SessionConversationTurn[],
|
||||
): SessionConversationTurn[] {
|
||||
const seenTurns = new Set<string>();
|
||||
const seenEntries = new Set<string>();
|
||||
const result: SessionConversationTurn[] = [];
|
||||
for (const turn of turns) {
|
||||
if (seenTurns.has(turn.turn_id)) continue;
|
||||
const entries = turn.entries.filter((entry) => {
|
||||
if (seenEntries.has(entry.entry_id)) return false;
|
||||
seenEntries.add(entry.entry_id);
|
||||
return true;
|
||||
});
|
||||
if (entries.length === 0) continue;
|
||||
seenTurns.add(turn.turn_id);
|
||||
result.push({ ...turn, entries });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isUserEntry(entry: SessionSnapshotEntry): boolean {
|
||||
return entry.kind === "user_input" ||
|
||||
(entry.kind === "message" && entry.role === "user");
|
||||
}
|
||||
|
||||
function isAssistantEntry(entry: SessionSnapshotEntry): boolean {
|
||||
return entry.kind === "message" && entry.role === "assistant";
|
||||
}
|
||||
|
||||
function entryText(entry: SessionSnapshotEntry): string {
|
||||
switch (entry.kind) {
|
||||
case "user_input":
|
||||
return entry.segments.map((segment) => {
|
||||
if (segment.kind === "text" || segment.kind === "paste") {
|
||||
return segment.content;
|
||||
}
|
||||
if (segment.kind === "file_ref") return `@file ${segment.path}`;
|
||||
if (segment.kind === "uploaded_file") {
|
||||
return `[Attachment: ${segment.file.file_name}]`;
|
||||
}
|
||||
if (segment.kind === "paste_artifact") {
|
||||
return `[Large paste ${segment.artifact.artifact_id}]`;
|
||||
}
|
||||
return "";
|
||||
}).filter(Boolean).join("\n");
|
||||
case "message":
|
||||
return entry.content.map((part) =>
|
||||
part.kind === "text" ? part.text : part.refusal
|
||||
).join("\n");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(value: string): string {
|
||||
return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "—";
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
consoleWorkerViews,
|
||||
createConsoleProjector,
|
||||
isConsoleProjectionEvent,
|
||||
mergeCommittedHistoryLines,
|
||||
projectConsole,
|
||||
projectConsoleLines,
|
||||
projectOverviewLines,
|
||||
@@ -2950,3 +2951,203 @@ Deno.test("new invoke resets stats before the next RunEnd", () => {
|
||||
|
||||
assertEquals(projection.lines.at(-1)?.body, "0s ・0 reqs ↑0/↓0");
|
||||
});
|
||||
|
||||
Deno.test("committed assistant identity reconciles the live block without text matching", () => {
|
||||
const lines = projectConsole([
|
||||
{
|
||||
eventId: "user-live",
|
||||
event: {
|
||||
event: "user_message",
|
||||
data: {
|
||||
entry_id: "user-entry",
|
||||
segments: [{ kind: "text", content: "question" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "assistant-live",
|
||||
event: { event: "text_done", data: { text: "final answer" } },
|
||||
},
|
||||
{
|
||||
eventId: "assistant-commit",
|
||||
event: {
|
||||
event: "session_entry_committed",
|
||||
data: {
|
||||
entry: {
|
||||
entry_id: "assistant-entry",
|
||||
timestamp: 7,
|
||||
provenance: "model_output",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
content: [{ kind: "text", text: "final answer" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]).lines;
|
||||
assertEquals(lines.filter((line) => line.kind === "assistant").length, 1);
|
||||
assertEquals(
|
||||
lines.find((line) => line.kind === "assistant")?.entryId,
|
||||
"assistant-entry",
|
||||
);
|
||||
assertEquals(
|
||||
lines.find((line) => line.kind === "user")?.entryId,
|
||||
"user-entry",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("committed tool identities reconcile one live call block", () => {
|
||||
const lines = projectConsole([
|
||||
{
|
||||
eventId: "tool-live",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: { id: "call-1", name: "Read", arguments: '{"file_path":"a"}' },
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "tool-call-commit",
|
||||
event: {
|
||||
event: "session_entry_committed",
|
||||
data: {
|
||||
entry: {
|
||||
entry_id: "tool-call-entry",
|
||||
timestamp: 8,
|
||||
provenance: "model_output",
|
||||
kind: "tool_call",
|
||||
call_id: "call-1",
|
||||
name: "Read",
|
||||
arguments: '{"file_path":"a"}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "tool-result-live",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "call-1",
|
||||
summary: "read",
|
||||
output: "contents",
|
||||
is_error: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: "tool-result-commit",
|
||||
event: {
|
||||
event: "session_entry_committed",
|
||||
data: {
|
||||
entry: {
|
||||
entry_id: "tool-result-entry",
|
||||
timestamp: 9,
|
||||
provenance: "tool_output",
|
||||
kind: "tool_result",
|
||||
call_id: "call-1",
|
||||
summary: "read",
|
||||
content: "contents",
|
||||
is_error: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]).lines;
|
||||
|
||||
assertEquals(
|
||||
lines.filter((line) => line.toolCall?.id === "call-1").length,
|
||||
1,
|
||||
);
|
||||
assertEquals(lines[0].entryId, "tool-result-entry");
|
||||
assertEquals(lines[0].toolCall?.state, "done");
|
||||
});
|
||||
|
||||
Deno.test("committed history preserves unmatched current rows in stable entry order", () => {
|
||||
const history = [2, 3, 4, 5, 6].flatMap((turn) => {
|
||||
const user = consoleLine(`history-user-${turn}`, "user");
|
||||
user.entryId = `user-${turn}`;
|
||||
const assistant = consoleLine(`history-assistant-${turn}`, "assistant");
|
||||
assistant.entryId = `assistant-${turn}`;
|
||||
return [user, assistant];
|
||||
});
|
||||
const current = [1, 2, 3, 4, 5, 6].flatMap((turn) => {
|
||||
const user = consoleLine(`current-user-${turn}`, "user");
|
||||
user.entryId = `user-${turn}`;
|
||||
const assistant = consoleLine(`current-assistant-${turn}`, "assistant");
|
||||
assistant.entryId = `assistant-${turn}`;
|
||||
return [user, assistant];
|
||||
});
|
||||
|
||||
const merged = mergeCommittedHistoryLines(history, current);
|
||||
assertEquals(
|
||||
merged.map((line) => line.entryId),
|
||||
[1, 2, 3, 4, 5, 6].flatMap((turn) => [
|
||||
`user-${turn}`,
|
||||
`assistant-${turn}`,
|
||||
]),
|
||||
);
|
||||
assert(
|
||||
merged.every((line) => line.id.startsWith("current-")),
|
||||
"the ordered current snapshot must remain authoritative for overlapping entries",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("committed history places omitted retained rows between snapshot anchors", () => {
|
||||
const makeLine = (prefix: string, turn: number) => {
|
||||
const line = consoleLine(`${prefix}-${turn}`, "user");
|
||||
line.entryId = `user-${turn}`;
|
||||
return line;
|
||||
};
|
||||
const history = [2, 3, 4, 5, 6].map((turn) => makeLine("history", turn));
|
||||
const current = [1, 2, 6].map((turn) => makeLine("current", turn));
|
||||
|
||||
assertEquals(
|
||||
mergeCommittedHistoryLines(history, current).map((line) => line.entryId),
|
||||
[1, 2, 3, 4, 5, 6].map((turn) => `user-${turn}`),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("committed history places current context before an interior first anchor", () => {
|
||||
const makeLine = (prefix: string, turn: number) => {
|
||||
const line = consoleLine(`${prefix}-${turn}`, "user");
|
||||
line.entryId = `user-${turn}`;
|
||||
return line;
|
||||
};
|
||||
const history = [6, 7, 8, 9, 10].map((turn) => makeLine("history", turn));
|
||||
const current = [1, 8, 10].map((turn) => makeLine("current", turn));
|
||||
|
||||
assertEquals(
|
||||
mergeCommittedHistoryLines(history, current).map((line) => line.entryId),
|
||||
[1, 6, 7, 8, 9, 10].map((turn) => `user-${turn}`),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("committed history places a retained suffix before newer current-only rows", () => {
|
||||
const makeLine = (prefix: string, turn: number) => {
|
||||
const line = consoleLine(`${prefix}-${turn}`, "user");
|
||||
line.entryId = `user-${turn}`;
|
||||
return line;
|
||||
};
|
||||
const history = [2, 3, 4, 5, 6].map((turn) => makeLine("history", turn));
|
||||
const current = [1, 2, 7].map((turn) => makeLine("current", turn));
|
||||
|
||||
assertEquals(
|
||||
mergeCommittedHistoryLines(history, current).map((line) => line.entryId),
|
||||
[1, 2, 3, 4, 5, 6, 7].map((turn) => `user-${turn}`),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("committed history inserts current live rows after their last stable anchor", () => {
|
||||
const historyUser = consoleLine("history-user", "user");
|
||||
historyUser.entryId = "user-1";
|
||||
const currentUser = consoleLine("current-user", "user");
|
||||
currentUser.entryId = "user-1";
|
||||
const live = consoleLine("live-assistant", "in_flight");
|
||||
|
||||
assertEquals(
|
||||
mergeCommittedHistoryLines([historyUser], [currentUser, live]).map((line) =>
|
||||
line.id
|
||||
),
|
||||
["current-user", "live-assistant"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
InternalWorkerRef,
|
||||
InternalWorkerSnapshot,
|
||||
Segment,
|
||||
SessionSnapshotEntry,
|
||||
WorkerState,
|
||||
WorkerStateSnapshot,
|
||||
WorkerStatus,
|
||||
@@ -90,6 +91,8 @@ export type ConsoleLine = {
|
||||
compaction?: ConsoleCompaction;
|
||||
diff?: ConsoleDiffLine[];
|
||||
eventId?: string | null;
|
||||
/** Stable durable identity when this line originates from a committed entry. */
|
||||
entryId?: string;
|
||||
source: "event";
|
||||
streaming?: boolean;
|
||||
error?: boolean;
|
||||
@@ -246,6 +249,86 @@ export function createConsoleProjector() {
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCommittedHistoryLines(
|
||||
history: ConsoleLine[],
|
||||
current: ConsoleLine[],
|
||||
): ConsoleLine[] {
|
||||
const currentEntryIds = new Set(
|
||||
current.map((line) => line.entryId).filter((id): id is string =>
|
||||
Boolean(id)
|
||||
),
|
||||
);
|
||||
const historyPositions = new Map<string, number>();
|
||||
history.forEach((line, index) => {
|
||||
if (line.entryId && !historyPositions.has(line.entryId)) {
|
||||
historyPositions.set(line.entryId, index);
|
||||
}
|
||||
});
|
||||
|
||||
// Use stable entries that occur in the same order in both projections as
|
||||
// anchors. History-only rows are inserted in their retained interval, while
|
||||
// current-only rows retain the snapshot's authoritative order. This handles
|
||||
// Compact snapshots that preserve only a non-contiguous subset of context.
|
||||
const anchors: Array<{ history: number; current: number }> = [];
|
||||
let lastHistoryIndex = -1;
|
||||
current.forEach((line, currentIndex) => {
|
||||
const historyIndex = line.entryId
|
||||
? historyPositions.get(line.entryId)
|
||||
: undefined;
|
||||
if (historyIndex !== undefined && historyIndex > lastHistoryIndex) {
|
||||
anchors.push({ history: historyIndex, current: currentIndex });
|
||||
lastHistoryIndex = historyIndex;
|
||||
}
|
||||
});
|
||||
|
||||
if (anchors.length === 0) {
|
||||
return [
|
||||
...history.filter((line) =>
|
||||
!line.entryId || !currentEntryIds.has(line.entryId)
|
||||
),
|
||||
...current,
|
||||
];
|
||||
}
|
||||
|
||||
const merged: ConsoleLine[] = [];
|
||||
let historyCursor = 0;
|
||||
let currentCursor = 0;
|
||||
const appendHistoryInterval = (end: number) => {
|
||||
for (; historyCursor < end; historyCursor += 1) {
|
||||
const line = history[historyCursor]!;
|
||||
if (!line.entryId || !currentEntryIds.has(line.entryId)) {
|
||||
merged.push(line);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const [index, anchor] of anchors.entries()) {
|
||||
if (index === 0) {
|
||||
// Current rows before the first shared entry can be older retained
|
||||
// context that the newest history page starts after.
|
||||
merged.push(...current.slice(currentCursor, anchor.current));
|
||||
appendHistoryInterval(anchor.history);
|
||||
} else {
|
||||
appendHistoryInterval(anchor.history);
|
||||
merged.push(...current.slice(currentCursor, anchor.current));
|
||||
}
|
||||
merged.push(current[anchor.current]!);
|
||||
historyCursor = anchor.history + 1;
|
||||
currentCursor = anchor.current + 1;
|
||||
}
|
||||
appendHistoryInterval(history.length);
|
||||
merged.push(...current.slice(currentCursor));
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function projectSessionHistoryEntries(
|
||||
entries: SessionSnapshotEntry[],
|
||||
cwd: string | null,
|
||||
): ConsoleLine[] {
|
||||
return projectVisibleConsole(
|
||||
snapshotProjectionFromSession("history", { entries }, cwd),
|
||||
).lines;
|
||||
}
|
||||
|
||||
function projectVisibleConsole(
|
||||
projection: ConsoleProjection,
|
||||
): ConsoleProjection {
|
||||
@@ -813,14 +896,19 @@ export function applyProtocolEvent(
|
||||
|
||||
switch (event.event) {
|
||||
case "user_message":
|
||||
next.lines.push(
|
||||
line(
|
||||
next.lines.push({
|
||||
...line(
|
||||
envelope.eventId,
|
||||
"user",
|
||||
"User",
|
||||
segmentsToText(event.data.segments),
|
||||
),
|
||||
);
|
||||
entryId: event.data.entry_id ?? undefined,
|
||||
});
|
||||
break;
|
||||
case "session_entry_committed":
|
||||
reconcileCommittedSessionEntry(next, event.data.entry);
|
||||
applySessionEntry(next, envelope.eventId, event.data.entry);
|
||||
break;
|
||||
case "system_item":
|
||||
next.lines.push(systemItemLine(envelope.eventId, event.data.item));
|
||||
@@ -1507,6 +1595,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
||||
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
|
||||
detail: calls.map(readDetail).join("\n\n"),
|
||||
eventId: group.at(-1)?.eventId,
|
||||
entryId: group.at(-1)?.entryId,
|
||||
source: "event",
|
||||
streaming: inProgress,
|
||||
error: hasError,
|
||||
@@ -1971,6 +2060,22 @@ function snapshotProjectionFromSession(
|
||||
return projection;
|
||||
}
|
||||
|
||||
function reconcileCommittedSessionEntry(
|
||||
projection: ConsoleProjection,
|
||||
entry: SessionSnapshotEntry,
|
||||
): void {
|
||||
if (entry.kind !== "message" || entry.role !== "assistant") return;
|
||||
for (let index = projection.lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = projection.lines[index];
|
||||
if (line.entryId) continue;
|
||||
if (line.kind === "assistant" || line.kind === "in_flight") {
|
||||
projection.lines.splice(index, 1);
|
||||
return;
|
||||
}
|
||||
if (line.kind === "user") return;
|
||||
}
|
||||
}
|
||||
|
||||
function applySessionEntry(
|
||||
projection: ConsoleProjection,
|
||||
fallbackEventId: string,
|
||||
@@ -1978,6 +2083,7 @@ function applySessionEntry(
|
||||
): void {
|
||||
if (!isRecord(value)) return;
|
||||
const eventId = stringField(value, "entry_id") ?? fallbackEventId;
|
||||
const lineCountBefore = projection.lines.length;
|
||||
switch (stringField(value, "kind")) {
|
||||
case "user_input":
|
||||
projection.lines.push(
|
||||
@@ -2035,6 +2141,18 @@ function applySessionEntry(
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const entryId = stringField(value, "entry_id");
|
||||
if (entryId) {
|
||||
for (
|
||||
let index = Math.max(0, lineCountBefore - 1);
|
||||
index < projection.lines.length;
|
||||
index += 1
|
||||
) {
|
||||
if (projection.lines[index].eventId === eventId) {
|
||||
projection.lines[index].entryId = entryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyLoggedUserInput(
|
||||
|
||||
@@ -34,3 +34,14 @@ export function consoleTurns(lines: readonly ConsoleLine[]): ConsoleTurn[] {
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
import type { SessionConversationTurn } from "$lib/generated/protocol";
|
||||
import { type ConsoleTurnPreview, conversationTurnPreviews } from "./history";
|
||||
|
||||
export type TurnNavigationItem = ConsoleTurnPreview;
|
||||
|
||||
export function buildTurnNavigationItems(
|
||||
turns: readonly SessionConversationTurn[],
|
||||
): TurnNavigationItem[] {
|
||||
return conversationTurnPreviews(turns);
|
||||
}
|
||||
|
||||
@@ -496,11 +496,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
assert(
|
||||
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
memoryDocumentLoad.includes('"/memory"') &&
|
||||
memoryDocumentPage.includes("Memory Document") &&
|
||||
memoryDocumentPage.includes("This view is read-only") &&
|
||||
memoryDocumentPage.includes("DocumentMarkdown from") &&
|
||||
memoryDocumentPage.includes("Read-only") &&
|
||||
memoryDocumentPage.includes("data.memory.data.body_md") &&
|
||||
memoryDocumentPage.includes("data.memory.data.updated_at"),
|
||||
"Memory Document page should read the scoped API and expose the durable document without mutation controls",
|
||||
memoryDocumentPage.includes("data.memory.data.updated_at") &&
|
||||
memoryDocumentPage.includes('role="alert"') &&
|
||||
!memoryDocumentPage.includes('<pre class="memory-document-body"'),
|
||||
"Memory Document page should read the scoped API and expose safe Markdown states without mutation controls",
|
||||
);
|
||||
assert(
|
||||
memoryStagingLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
@@ -790,19 +792,19 @@ Deno.test("Worker Console uses turn bars with hidden scrollbars and preserves sc
|
||||
const navigation = await Deno.readTextFile(new URL("./ConsoleTurnNavigation.svelte", import.meta.url));
|
||||
assert(
|
||||
!/timeline/i.test(consolePage) &&
|
||||
consolePage.includes("const turns = $derived(consoleTurns(lines))") &&
|
||||
consolePage.includes("<ConsoleTurnNavigation {turns} onSelect={selectConsoleTurn}") &&
|
||||
consolePage.includes("conversationTurnPreviewsFromLines(lines)") &&
|
||||
consolePage.includes("items={turnNavigationItems}") &&
|
||||
consolePage.includes("CSS.escape(id)") &&
|
||||
consolePage.includes("scrollbar-width: none") &&
|
||||
consolePage.includes("::-webkit-scrollbar"),
|
||||
"Console should replace the scrollbar with conversation turn navigation, not the former timeline",
|
||||
);
|
||||
assert(
|
||||
navigation.includes("{#each turns as turn, index (turn.id)}") &&
|
||||
navigation.includes("{#each items as turn, index (turn.turnId)}") &&
|
||||
navigation.includes('class="turn-bar"') &&
|
||||
navigation.includes("onmouseenter=") &&
|
||||
navigation.includes("onfocus=") &&
|
||||
navigation.includes("onclick={() => onSelect(turn.id)}") &&
|
||||
navigation.includes("onclick={() => onTurnClick(turn)}") &&
|
||||
navigation.includes('role="tooltip"') &&
|
||||
navigation.includes("aria-describedby=") &&
|
||||
navigation.includes("event.key === 'Escape'") &&
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
lang = "",
|
||||
text,
|
||||
}: {
|
||||
lang?: string;
|
||||
text: string;
|
||||
codeBlockStyle?: "indented";
|
||||
} = $props();
|
||||
|
||||
const label = $derived(lang ? `${lang} code block` : "Code block");
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (horizontal scroll regions must be keyboard-focusable) -->
|
||||
<div class="markdown-code-region" role="region" aria-label={label} tabindex="0">
|
||||
<pre><code>{text}</code></pre>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import DocumentMarkdown from "./DocumentMarkdown.svelte";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("renders document Markdown semantics through the shared safe policy", async () => {
|
||||
const source = [
|
||||
"# Memory heading",
|
||||
"",
|
||||
"##### Deep heading",
|
||||
"",
|
||||
"###### Deepest heading",
|
||||
"",
|
||||
"A paragraph with **strong context** and `inline-code`.",
|
||||
"",
|
||||
"- first item",
|
||||
"- second item",
|
||||
"",
|
||||
"> durable quote",
|
||||
"",
|
||||
"| Decision | Owner |",
|
||||
"| --- | --- |",
|
||||
"| Keep the document safe | Workspace |",
|
||||
"",
|
||||
"```rust",
|
||||
'fn main() { println!("safe"); }',
|
||||
"```",
|
||||
"",
|
||||
"[safe](https://example.com) [script](javascript:alert(1)) [relative](/internal)",
|
||||
"",
|
||||
'<img src="x" onerror="alert(1)"><script>alert(1)</script>',
|
||||
].join("\n");
|
||||
const { container } = render(DocumentMarkdown, { text: source });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Memory heading");
|
||||
expect(container.querySelector("table")).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(container.querySelector("h5")?.textContent).toBe("Deep heading");
|
||||
expect(container.querySelector("h6")?.textContent).toBe("Deepest heading");
|
||||
expect(container.querySelector("strong")?.textContent).toBe("strong context");
|
||||
expect(container.querySelectorAll("li")).toHaveLength(2);
|
||||
expect(container.querySelector("blockquote")?.textContent).toContain(
|
||||
"durable quote",
|
||||
);
|
||||
expect(container.querySelector('a[href="https://example.com"]')).not
|
||||
.toBeNull();
|
||||
expect(container.querySelectorAll("a")).toHaveLength(1);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
|
||||
const tableRegion = container.querySelector<HTMLElement>(
|
||||
'[role="region"][aria-label="Markdown table"]',
|
||||
);
|
||||
const codeRegion = container.querySelector<HTMLElement>(
|
||||
'[role="region"][aria-label="rust code block"]',
|
||||
);
|
||||
expect(tableRegion?.tabIndex).toBe(0);
|
||||
expect(codeRegion?.tabIndex).toBe(0);
|
||||
expect(codeRegion?.querySelector("pre > code")?.textContent).toContain(
|
||||
"println!",
|
||||
);
|
||||
});
|
||||
|
||||
test("wraps long prose and identifiers without turning them into document-level controls", async () => {
|
||||
const longIdentifier = "memory-identifier-".repeat(40);
|
||||
const { container } = render(DocumentMarkdown, {
|
||||
text:
|
||||
`Long value: ${longIdentifier}\n\n<details open><summary>unsafe HTML</summary></details>`,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.textContent).toContain(longIdentifier));
|
||||
expect(container.querySelector("details")).toBeNull();
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import type { Renderers } from "@humanspeak/svelte-markdown";
|
||||
import DocumentCodeBlock from "./DocumentCodeBlock.svelte";
|
||||
import MarkdownTable from "./MarkdownTable.svelte";
|
||||
import SafeMarkdown from "./SafeMarkdown.svelte";
|
||||
|
||||
let { text }: { text: string } = $props();
|
||||
|
||||
const renderers = {
|
||||
code: DocumentCodeBlock,
|
||||
table: MarkdownTable,
|
||||
} satisfies Partial<Renderers>;
|
||||
</script>
|
||||
|
||||
<SafeMarkdown {text} class="document-markdown" {renderers} />
|
||||
|
||||
<style>
|
||||
:global(.document-markdown) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--text);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:global(.document-markdown > :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:global(.document-markdown > :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.document-markdown h1) {
|
||||
margin: var(--space-6) 0 var(--space-4);
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-title);
|
||||
line-height: var(--line-height-title);
|
||||
}
|
||||
|
||||
:global(.document-markdown h2),
|
||||
:global(.document-markdown h3),
|
||||
:global(.document-markdown h4),
|
||||
:global(.document-markdown h5),
|
||||
:global(.document-markdown h6) {
|
||||
margin: var(--space-6) 0 var(--space-2);
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
:global(.document-markdown h2) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:global(.document-markdown h3),
|
||||
:global(.document-markdown h4),
|
||||
:global(.document-markdown h5),
|
||||
:global(.document-markdown h6) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.document-markdown p),
|
||||
:global(.document-markdown ul),
|
||||
:global(.document-markdown ol),
|
||||
:global(.document-markdown blockquote),
|
||||
:global(.document-markdown .markdown-code-region),
|
||||
:global(.document-markdown .markdown-table-region) {
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
|
||||
:global(.document-markdown ul),
|
||||
:global(.document-markdown ol) {
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
:global(.document-markdown li + li) {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
:global(.document-markdown blockquote) {
|
||||
margin-left: 0;
|
||||
padding-left: var(--space-4);
|
||||
border-left: 2px solid var(--line-strong);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
:global(.document-markdown a) {
|
||||
color: var(--accent);
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
|
||||
:global(.document-markdown :not(pre) > code) {
|
||||
padding: 0 var(--space-1);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--code);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-code-region),
|
||||
:global(.document-markdown .markdown-table-region) {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-code-region) {
|
||||
background: var(--bg-raised);
|
||||
color: var(--code);
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-code-region pre) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-table-region table) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-table-region th),
|
||||
:global(.document-markdown .markdown-table-region td) {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-table-region th) {
|
||||
color: var(--text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.document-markdown hr) {
|
||||
margin: var(--space-6) 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { children }: { children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (horizontal scroll regions must be keyboard-focusable) -->
|
||||
<div class="markdown-table-region" role="region" aria-label="Markdown table" tabindex="0">
|
||||
<table>{@render children?.()}</table>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import SvelteMarkdown, {
|
||||
buildUnsupportedHTML,
|
||||
type Renderers,
|
||||
type SvelteMarkdownProps,
|
||||
} from "@humanspeak/svelte-markdown";
|
||||
import MarkdownLink from "$lib/workspace/markdown/MarkdownLink.svelte";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
streamId?: string | number;
|
||||
class?: string;
|
||||
streaming?: boolean;
|
||||
renderers?: Partial<Renderers>;
|
||||
};
|
||||
|
||||
let {
|
||||
text,
|
||||
streamId = "static",
|
||||
class: className = "",
|
||||
streaming = false,
|
||||
renderers = {},
|
||||
}: Props = $props();
|
||||
|
||||
const options = {
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
|
||||
|
||||
const safeRenderers = $derived({
|
||||
...renderers,
|
||||
html: buildUnsupportedHTML(),
|
||||
link: MarkdownLink,
|
||||
} satisfies Partial<Renderers>);
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<SvelteMarkdown source={text} {streamId} {options} renderers={safeRenderers} {streaming} />
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExternalWorkdirPermissions } from "$lib/generated/workdir-api";
|
||||
|
||||
/** Render independent Workdir authority categories in a stable, non-conflating order. */
|
||||
/** Render an effective attachment/session capability intersection without widening it. */
|
||||
export function formatWorkdirPermissions(
|
||||
permissions: ExternalWorkdirPermissions,
|
||||
): string {
|
||||
@@ -10,3 +10,21 @@ export function formatWorkdirPermissions(
|
||||
if (permissions.command) categories.push("COMMAND");
|
||||
return categories.length > 0 ? categories.join(" · ") : "NONE";
|
||||
}
|
||||
|
||||
/** Render one validated External grant level with its inherited authority. */
|
||||
export function formatExternalGrantPermissionLevel(
|
||||
permissions: ExternalWorkdirPermissions,
|
||||
): string {
|
||||
if (permissions.read && permissions.write && permissions.command) {
|
||||
return "COMMAND (includes WRITE + READ)";
|
||||
}
|
||||
if (permissions.read && permissions.write && !permissions.command) {
|
||||
return "WRITE (includes READ)";
|
||||
}
|
||||
if (permissions.read && !permissions.write && !permissions.command) {
|
||||
return "READ";
|
||||
}
|
||||
throw new Error(
|
||||
"External Workdir grant has a non-hierarchical permission set",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,123 +1,165 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate } from '$lib/workspace/api/http';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const lineCount = $derived(data.memory.data?.body_md.split('\n').length ?? 0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Memory Document · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory Document" />
|
||||
<title>Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory document" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="card memory-document-card">
|
||||
<div class="detail-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Workspace memory</p>
|
||||
<h2>Memory Document</h2>
|
||||
</div>
|
||||
{#if data.memory.data}
|
||||
<span>{data.memory.data.bytes} bytes</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="section-note">
|
||||
Durable workspace Memory as a single Markdown document. This view is read-only.
|
||||
</p>
|
||||
|
||||
<section class="memory-document-page" aria-label="Memory document">
|
||||
{#if data.memory.data}
|
||||
<div class="memory-document-summary" aria-label="Memory document summary">
|
||||
<div>
|
||||
<span>Updated</span>
|
||||
<strong>{formatDate(data.memory.data.updated_at)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Created</span>
|
||||
<strong>{formatDate(data.memory.data.created_at)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Lines</span>
|
||||
<strong>{lineCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Source</span>
|
||||
<strong>{data.memory.data.record_source}</strong>
|
||||
</div>
|
||||
<div class="memory-document-meta">
|
||||
<p>
|
||||
<span>Read-only</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Updated <time datetime={data.memory.data.updated_at}>{formatDate(data.memory.data.updated_at)}</time></span>
|
||||
</p>
|
||||
<details>
|
||||
<summary>Document details</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd><time datetime={data.memory.data.created_at}>{formatDate(data.memory.data.created_at)}</time></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{data.memory.data.bytes} bytes</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd><code>{data.memory.data.record_source}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{#if data.memory.data.body_md.trim().length === 0}
|
||||
<p>No Memory document content is present.</p>
|
||||
<div class="memory-document-state" role="status">
|
||||
<strong>Memory document is empty.</strong>
|
||||
<p>Durable Workspace context will appear here when it is available.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<pre class="memory-document-body">{data.memory.data.body_md}</pre>
|
||||
<article class="memory-document-content" aria-label="Memory document content">
|
||||
<DocumentMarkdown text={data.memory.data.body_md} />
|
||||
</article>
|
||||
{/if}
|
||||
{:else if data.memory.error}
|
||||
<p class="error">{data.memory.error}</p>
|
||||
<div class="memory-document-state memory-document-error">
|
||||
<p role="alert"><strong>Memory document unavailable.</strong> {data.memory.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/w/{data.workspaceId}/memory</code>…</p>
|
||||
<div class="memory-document-state" role="status">
|
||||
<p>Memory document data is unavailable.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.memory-document-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-document-summary {
|
||||
.memory-document-page {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-5);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 78rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.memory-document-summary div {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--bg-raised);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.memory-document-summary span {
|
||||
.memory-document-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.memory-document-summary strong {
|
||||
.memory-document-meta > p {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.memory-document-meta details {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.memory-document-meta summary {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-document-meta dl {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: min(24rem, calc(100vw - (2 * var(--space-4))));
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: var(--space-3) 0 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-document-meta dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.memory-document-meta dt {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.memory-document-meta dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
display: block;
|
||||
font-size: var(--font-size-compact);
|
||||
margin-top: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-document-body {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
margin: 1rem 0 0;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
white-space: pre-wrap;
|
||||
.memory-document-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.memory-document-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.memory-document-state {
|
||||
padding-block: var(--space-5);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-document-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.memory-document-state p {
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.memory-document-state > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.memory-document-error,
|
||||
.memory-document-error strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.memory-document-meta {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.memory-document-summary {
|
||||
grid-template-columns: 1fr;
|
||||
.memory-document-meta details {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.memory-document-meta dl {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import MemoryPage from "./+page.svelte";
|
||||
import type { PageProps } from "./$types";
|
||||
|
||||
function pageProps(memory: {
|
||||
data: {
|
||||
body_md: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bytes: number;
|
||||
record_source: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}): PageProps {
|
||||
return {
|
||||
data: { workspaceId: "workspace-1", memory },
|
||||
params: { workspaceId: "workspace-1" },
|
||||
} as unknown as PageProps;
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("makes the Markdown document primary and keeps technical metadata in disclosure", async () => {
|
||||
const { container } = render(
|
||||
MemoryPage,
|
||||
pageProps({
|
||||
data: {
|
||||
body_md: "# Durable context\n\nRead the **current** document.",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 48,
|
||||
record_source: "sqlite_workspace_authority.memory_document",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Durable context", level: 1 })).not.toBeNull();
|
||||
});
|
||||
expect(screen.getByText("Read-only")).not.toBeNull();
|
||||
expect(screen.getByText("Document details")).not.toBeNull();
|
||||
expect(container.querySelector(".memory-document-page")?.classList.contains("card")).toBe(false);
|
||||
expect(screen.queryByRole("heading", { name: "Memory Document" })).toBeNull();
|
||||
expect(screen.getByRole("article", { name: "Memory document content" })).not.toBeNull();
|
||||
});
|
||||
|
||||
test("distinguishes an empty document from request failure and unavailable data", async () => {
|
||||
const view = render(
|
||||
MemoryPage,
|
||||
pageProps({
|
||||
data: {
|
||||
body_md: " \n",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 3,
|
||||
record_source: "fixture",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole("status").textContent).toContain("Memory document is empty.");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
|
||||
await view.rerender(pageProps({ data: null, error: "Memory API request failed" }));
|
||||
expect(screen.getByRole("alert").textContent).toContain("Memory document unavailable.");
|
||||
expect(screen.getByRole("alert").textContent).toContain("Memory API request failed");
|
||||
expect(screen.queryByText("Memory document is empty.")).toBeNull();
|
||||
|
||||
await view.rerender(pageProps({ data: null, error: null }));
|
||||
expect(screen.getByRole("status").textContent).toBe("Memory document data is unavailable.");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
+372
-34
@@ -3,7 +3,6 @@
|
||||
import { tick, untrack, type SvelteComponent } from "svelte";
|
||||
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
|
||||
import ConsoleTurnNavigation from "$lib/workspace/console/ConsoleTurnNavigation.svelte";
|
||||
import { consoleTurns } from "$lib/workspace/console/turn-navigation";
|
||||
import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte";
|
||||
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
|
||||
import { namePastedImage } from "$lib/workspace/console/composer-paste";
|
||||
@@ -30,7 +29,9 @@
|
||||
consoleWorkerViews,
|
||||
createConsoleProjector,
|
||||
isConsoleProjectionEvent,
|
||||
mergeCommittedHistoryLines,
|
||||
projectConsoleLines,
|
||||
projectSessionHistoryEntries,
|
||||
resolveConsoleViewScrollTop,
|
||||
resolveConsoleWorkerView,
|
||||
type ConsoleEventInput,
|
||||
@@ -44,6 +45,7 @@
|
||||
PendingSubmissionsSnapshot,
|
||||
RewindTarget,
|
||||
Segment,
|
||||
SessionHistoryPage,
|
||||
} from "$lib/generated/protocol";
|
||||
import {
|
||||
MAX_FILES_PER_SUBMISSION,
|
||||
@@ -69,6 +71,18 @@
|
||||
type WorkerSessionRequestIdentity,
|
||||
type WorkerSessionTarget,
|
||||
} from "$lib/workspace/session-observation";
|
||||
import {
|
||||
applyConsoleHistoryPage,
|
||||
beginConsoleHistoryRequest,
|
||||
conversationTurnPreviewsFromLines,
|
||||
emptyConsoleHistoryState,
|
||||
failConsoleHistoryRequest,
|
||||
historyEntries,
|
||||
setConsoleHistoryTopEdge,
|
||||
shouldLoadHistoryAtTop,
|
||||
type ConsoleHistoryState,
|
||||
} from "$lib/workspace/console/history";
|
||||
import type { TurnNavigationItem } from "$lib/workspace/console/turn-navigation";
|
||||
import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types";
|
||||
|
||||
type Props = {
|
||||
@@ -162,6 +176,7 @@
|
||||
let workerViewSelectionGeneration = 0;
|
||||
let consoleViewMode = $state<ConsoleViewMode>("overview");
|
||||
let consoleBodyElement: HTMLElement | null = null;
|
||||
let turnNavigationElement = $state<HTMLElement | null>(null);
|
||||
let composerInputElement = $state<
|
||||
(SvelteComponent & ComposerInputHandle) | null
|
||||
>(null);
|
||||
@@ -176,6 +191,8 @@
|
||||
let consoleProjection = $state.raw<ConsoleProjection>(
|
||||
consoleProjector.snapshot(),
|
||||
);
|
||||
let historyByView = $state.raw<Record<string, ConsoleHistoryState>>({});
|
||||
const pendingHistoryRefreshes = new Set<string>();
|
||||
let pendingObservationEvents: ConsoleEventInput[] = [];
|
||||
let pendingInitialSnapshotApplication: {
|
||||
eventId: string;
|
||||
@@ -205,10 +222,30 @@
|
||||
),
|
||||
);
|
||||
const selectedConsoleProjection = $derived(selectedWorkerView.console);
|
||||
const lines = $derived(
|
||||
const selectedHistoryKey = $derived(
|
||||
`${workspaceId}:${runtimeId ?? ""}:${workerId ?? ""}:${selectedWorkerViewSessionId ?? "main"}`,
|
||||
);
|
||||
const selectedHistory = $derived(
|
||||
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
|
||||
);
|
||||
const currentLines = $derived(
|
||||
projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode),
|
||||
);
|
||||
const turns = $derived(consoleTurns(lines));
|
||||
const committedHistoryLines = $derived(
|
||||
projectConsoleLines(
|
||||
projectSessionHistoryEntries(
|
||||
historyEntries(selectedHistory),
|
||||
selectedConsoleProjection.cwd,
|
||||
),
|
||||
consoleViewMode,
|
||||
),
|
||||
);
|
||||
const lines = $derived(
|
||||
mergeCommittedHistoryLines(committedHistoryLines, currentLines),
|
||||
);
|
||||
const turnNavigationItems = $derived(
|
||||
conversationTurnPreviewsFromLines(lines),
|
||||
);
|
||||
const tasks = $derived(selectedConsoleProjection.tasks);
|
||||
const diagnostics = $derived(
|
||||
mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics),
|
||||
@@ -264,6 +301,233 @@
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
type WorkerSessionHistoryResponse =
|
||||
| { availability: "page"; page: SessionHistoryPage }
|
||||
| {
|
||||
availability: "unavailable";
|
||||
reason: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function mainHistoryKey(target: ConsoleTarget): string {
|
||||
return `${target.workspaceId}:${target.runtimeId}:${target.workerId}:main`;
|
||||
}
|
||||
|
||||
function historyStateFor(key: string): ConsoleHistoryState {
|
||||
return historyByView[key] ?? emptyConsoleHistoryState();
|
||||
}
|
||||
|
||||
function setHistoryState(key: string, state: ConsoleHistoryState) {
|
||||
historyByView = { ...historyByView, [key]: state };
|
||||
}
|
||||
|
||||
async function requestHistoryPage(
|
||||
target: ConsoleTarget,
|
||||
key: string,
|
||||
cursor: string | null,
|
||||
token: number,
|
||||
) {
|
||||
const started = beginConsoleHistoryRequest(historyStateFor(key), cursor);
|
||||
if (!started) return;
|
||||
setHistoryState(key, started);
|
||||
|
||||
const anchors = cursor === null ? null : captureHistoryAnchors();
|
||||
const query = new URLSearchParams({ limit: "5" });
|
||||
if (cursor) query.set("cursor", cursor);
|
||||
const path = workerApiPath(
|
||||
`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/session/history?${query}`,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (response.status === 404 || response.status === 405) {
|
||||
throw new Error("Earlier conversation is not supported by this Runtime version.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`History request failed (${response.status}).`);
|
||||
}
|
||||
const payload = (await response.json()) as WorkerSessionHistoryResponse;
|
||||
if (token !== reloadToken || mainHistoryKey(target) !== key) return;
|
||||
if (pendingHistoryRefreshes.has(key)) return;
|
||||
if (payload.availability === "unavailable") {
|
||||
setHistoryState(
|
||||
key,
|
||||
failConsoleHistoryRequest(historyStateFor(key), payload.message),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setHistoryState(
|
||||
key,
|
||||
applyConsoleHistoryPage(historyStateFor(key), payload.page, cursor),
|
||||
);
|
||||
await tick();
|
||||
restoreHistoryAnchors(anchors);
|
||||
} catch (error) {
|
||||
if (
|
||||
token !== reloadToken ||
|
||||
mainHistoryKey(target) !== key ||
|
||||
pendingHistoryRefreshes.has(key)
|
||||
)
|
||||
return;
|
||||
setHistoryState(
|
||||
key,
|
||||
failConsoleHistoryRequest(
|
||||
historyStateFor(key),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
const refreshAgain = pendingHistoryRefreshes.delete(key);
|
||||
if (
|
||||
refreshAgain &&
|
||||
token === reloadToken &&
|
||||
mainHistoryKey(target) === key
|
||||
) {
|
||||
const stale = historyStateFor(key);
|
||||
setHistoryState(key, {
|
||||
...stale,
|
||||
status: stale.turns.length > 0 ? "ready" : "idle",
|
||||
error: null,
|
||||
requestedCursor: null,
|
||||
});
|
||||
void requestHistoryPage(target, key, null, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type HistoryAnchor = { id: string; top: number };
|
||||
type HistoryAnchors = {
|
||||
transcript: HistoryAnchor | null;
|
||||
navigation: HistoryAnchor | null;
|
||||
};
|
||||
|
||||
function captureElementAnchor(
|
||||
container: HTMLElement | null,
|
||||
selector: string,
|
||||
dataKey: "consoleLineId" | "turnId",
|
||||
): HistoryAnchor | null {
|
||||
const first = container?.querySelector<HTMLElement>(selector);
|
||||
const id = first?.dataset[dataKey] ?? "";
|
||||
return first && id ? { id, top: first.getBoundingClientRect().top } : null;
|
||||
}
|
||||
|
||||
function captureHistoryAnchors(): HistoryAnchors {
|
||||
return {
|
||||
transcript: captureElementAnchor(
|
||||
consoleBodyElement,
|
||||
"[data-console-line-id]",
|
||||
"consoleLineId",
|
||||
),
|
||||
navigation: captureElementAnchor(
|
||||
turnNavigationElement,
|
||||
"[data-turn-id]",
|
||||
"turnId",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreElementAnchor(
|
||||
container: HTMLElement | null,
|
||||
selector: string,
|
||||
anchor: HistoryAnchor | null,
|
||||
) {
|
||||
if (!anchor || !container) return;
|
||||
const target = container.querySelector<HTMLElement>(selector);
|
||||
if (!target) return;
|
||||
container.scrollTop += target.getBoundingClientRect().top - anchor.top;
|
||||
}
|
||||
|
||||
function restoreHistoryAnchors(anchors: HistoryAnchors | null) {
|
||||
if (!anchors) return;
|
||||
restoreElementAnchor(
|
||||
consoleBodyElement,
|
||||
`[data-console-line-id="${CSS.escape(anchors.transcript?.id ?? "")}"]`,
|
||||
anchors.transcript,
|
||||
);
|
||||
restoreElementAnchor(
|
||||
turnNavigationElement,
|
||||
`[data-turn-id="${CSS.escape(anchors.navigation?.id ?? "")}"]`,
|
||||
anchors.navigation,
|
||||
);
|
||||
}
|
||||
|
||||
function loadEarlierHistory() {
|
||||
const target = consoleTarget;
|
||||
if (!target || selectedWorkerViewSessionId !== null) return;
|
||||
const key = mainHistoryKey(target);
|
||||
const state = historyStateFor(key);
|
||||
if (!state.hasMore || !state.cursor) return;
|
||||
void requestHistoryPage(target, key, state.cursor, reloadToken);
|
||||
}
|
||||
|
||||
function retryHistoryLoad() {
|
||||
const target = consoleTarget;
|
||||
if (!target || selectedWorkerViewSessionId !== null) return;
|
||||
const key = mainHistoryKey(target);
|
||||
const state = historyStateFor(key);
|
||||
void requestHistoryPage(
|
||||
target,
|
||||
key,
|
||||
state.hasMore ? state.cursor : null,
|
||||
reloadToken,
|
||||
);
|
||||
}
|
||||
|
||||
function fenceCurrentHistoryForRewind() {
|
||||
const target = consoleTarget;
|
||||
if (!target) return;
|
||||
const key = mainHistoryKey(target);
|
||||
const current = historyStateFor(key);
|
||||
const loading = current.status === "loading";
|
||||
const reset = emptyConsoleHistoryState();
|
||||
setHistoryState(key, {
|
||||
...reset,
|
||||
status: loading ? "loading" : "idle",
|
||||
requestedCursor: loading ? current.requestedCursor : null,
|
||||
topEdgeArmed: current.topEdgeArmed,
|
||||
});
|
||||
if (loading) pendingHistoryRefreshes.add(key);
|
||||
}
|
||||
|
||||
function refreshCurrentHistory() {
|
||||
const target = consoleTarget;
|
||||
if (!target) return;
|
||||
const key = mainHistoryKey(target);
|
||||
if (historyStateFor(key).status === "loading") {
|
||||
pendingHistoryRefreshes.add(key);
|
||||
return;
|
||||
}
|
||||
void requestHistoryPage(target, key, null, reloadToken);
|
||||
}
|
||||
|
||||
function handleHistoryTopEdge(atTop: boolean) {
|
||||
if (selectedWorkerViewSessionId !== null) return;
|
||||
const key = selectedHistoryKey;
|
||||
const state = historyStateFor(key);
|
||||
if (shouldLoadHistoryAtTop(state, atTop)) {
|
||||
loadEarlierHistory();
|
||||
} else if (!atTop) {
|
||||
const rearmed = setConsoleHistoryTopEdge(state, false);
|
||||
if (rearmed !== state) setHistoryState(key, rearmed);
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToConversationTurn(item: TurnNavigationItem) {
|
||||
const line = lines.find(
|
||||
(candidate) => candidate.entryId === item.lineId || candidate.id === item.lineId,
|
||||
);
|
||||
const target = line
|
||||
? consoleBodyElement?.querySelector(
|
||||
`[data-console-line-id="${CSS.escape(line.id)}"]`,
|
||||
)
|
||||
: null;
|
||||
if (target instanceof HTMLElement && line) {
|
||||
selectConsoleTurn(line.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorker(target: ConsoleTarget, token: number) {
|
||||
workerError = null;
|
||||
try {
|
||||
@@ -406,6 +670,22 @@
|
||||
liveWorkerState = consoleProjection.status === "shutdown"
|
||||
? "shutdown"
|
||||
: workerStateFromSnapshot(consoleProjection.workerState);
|
||||
if (
|
||||
eventBatch.some((event) => event.event.event === "rewind_applied")
|
||||
) {
|
||||
fenceCurrentHistoryForRewind();
|
||||
}
|
||||
if (
|
||||
eventBatch.some((event) =>
|
||||
event.event.event === "snapshot" ||
|
||||
event.event.event === "segment_rotated" ||
|
||||
event.event.event === "rewind_applied" ||
|
||||
event.event.event === "user_message" ||
|
||||
event.event.event === "session_entry_committed"
|
||||
)
|
||||
) {
|
||||
refreshCurrentHistory();
|
||||
}
|
||||
if (
|
||||
initialSnapshotApplication &&
|
||||
eventBatch.some(
|
||||
@@ -1338,6 +1618,7 @@
|
||||
}
|
||||
autoFollowConsole = isNearConsoleBottom(consoleBodyElement);
|
||||
rememberConsoleWorkerViewScroll();
|
||||
handleHistoryTopEdge(consoleBodyElement.scrollTop <= 1);
|
||||
}
|
||||
|
||||
async function scrollConsoleToBottom() {
|
||||
@@ -1543,40 +1824,67 @@
|
||||
|
||||
<div class:with-task-pane={taskPaneOpen} class="console-history">
|
||||
<section class="console-body">
|
||||
<div
|
||||
class="console-scroll"
|
||||
bind:this={consoleBodyElement}
|
||||
onscroll={handleConsoleScroll}
|
||||
<div
|
||||
class="console-scroll"
|
||||
bind:this={consoleBodyElement}
|
||||
onscroll={handleConsoleScroll}
|
||||
>
|
||||
<article
|
||||
class="card console-card worker-console-card"
|
||||
aria-label={`${selectedWorkerView.label} transcript`}
|
||||
>
|
||||
<article
|
||||
class="card console-card worker-console-card"
|
||||
aria-label={`${selectedWorkerView.label} transcript`}
|
||||
>
|
||||
{#if workerError}
|
||||
<p class="error">{workerError}</p>
|
||||
{/if}
|
||||
{#if workerError}
|
||||
<p class="error">{workerError}</p>
|
||||
{/if}
|
||||
|
||||
<ConsoleDisplayStateView
|
||||
state={consoleDisplayState}
|
||||
hasContent={lines.length > 0}
|
||||
hasUnfilteredContent={selectedConsoleProjection.lines.length > 0}
|
||||
onRetry={retryConsoleLoad}
|
||||
/>
|
||||
{#if selectedWorkerViewSessionId === null && (selectedHistory.hasMore || selectedHistory.error || selectedHistory.status === "loading")}
|
||||
<div class="conversation-history-boundary">
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedHistory.status === "loading"}
|
||||
onclick={selectedHistory.error ? retryHistoryLoad : loadEarlierHistory}
|
||||
>
|
||||
{selectedHistory.status === "loading"
|
||||
? "Loading earlier conversation…"
|
||||
: selectedHistory.error
|
||||
? "Earlier conversation unavailable · retry"
|
||||
: "Earlier conversation available · load 5 turns"}
|
||||
</button>
|
||||
</div>
|
||||
{:else if selectedWorkerViewSessionId === null && selectedHistory.status === "ready"}
|
||||
<div class="conversation-history-boundary complete">Start of conversation</div>
|
||||
{/if}
|
||||
|
||||
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
|
||||
<ol class="console-log">
|
||||
{#each lines as item (item.id)}
|
||||
<ConsoleLineItem {item} />
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && turns.length > 0}
|
||||
{#key `${workerId}:${selectedWorkerView.sessionId}`}
|
||||
<ConsoleTurnNavigation {turns} onSelect={selectConsoleTurn} />
|
||||
{/key}
|
||||
{/if}
|
||||
<ConsoleDisplayStateView
|
||||
state={consoleDisplayState}
|
||||
hasContent={lines.length > 0}
|
||||
hasUnfilteredContent={selectedConsoleProjection.lines.length > 0}
|
||||
onRetry={retryConsoleLoad}
|
||||
/>
|
||||
|
||||
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
|
||||
<ol class="console-log">
|
||||
{#each lines as item (item.id)}
|
||||
<ConsoleLineItem {item} />
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<ConsoleTurnNavigation
|
||||
items={turnNavigationItems}
|
||||
hasMore={selectedWorkerViewSessionId === null && selectedHistory.hasMore}
|
||||
loading={selectedHistory.status === "loading"}
|
||||
error={selectedWorkerViewSessionId === null
|
||||
? selectedHistory.error
|
||||
: "Earlier history is unavailable for direct SubWorker views."}
|
||||
onLoadMore={selectedHistory.error ? retryHistoryLoad : loadEarlierHistory}
|
||||
onTopEdgeChange={handleHistoryTopEdge}
|
||||
onTurnClick={jumpToConversationTurn}
|
||||
historyAvailable={selectedWorkerViewSessionId === null}
|
||||
bind:element={turnNavigationElement}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{#if taskPaneOpen}
|
||||
@@ -2041,6 +2349,36 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.conversation-history-boundary {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.conversation-history-boundary button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--tui-cyan);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.conversation-history-boundary button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.conversation-history-boundary.complete::before,
|
||||
.conversation-history-boundary.complete::after {
|
||||
content: "";
|
||||
align-self: center;
|
||||
width: 2rem;
|
||||
margin: 0 var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pending-submissions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
+456
-16
@@ -6,10 +6,12 @@ import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
Event as ProtocolEvent,
|
||||
SessionConversationTurn,
|
||||
SessionSnapshot,
|
||||
} from "$lib/generated/protocol";
|
||||
import type { Worker } from "$lib/workspace/sidebar/types";
|
||||
@@ -106,6 +108,63 @@ function sessionWithUserMessage(content: string): SessionSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function emptyHistoryPage() {
|
||||
return {
|
||||
availability: "page",
|
||||
page: {
|
||||
session_id: "session-a",
|
||||
lineage_id: "lineage-a",
|
||||
compact_ancestor_lineage_ids: [],
|
||||
turns: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function historyPage(
|
||||
indices: number[],
|
||||
cursor: string | null,
|
||||
lineageId = "lineage-a",
|
||||
parentLineage?: {
|
||||
lineage_id: string;
|
||||
adopted_through_turn?: SessionConversationTurn | null;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
availability: "page",
|
||||
page: {
|
||||
session_id: "session-a",
|
||||
lineage_id: lineageId,
|
||||
compact_ancestor_lineage_ids: [],
|
||||
parent_lineage: parentLineage,
|
||||
turns: indices.map((index) => ({
|
||||
turn_id: `user-${index}`,
|
||||
entries: [
|
||||
{
|
||||
entry_id: `user-${index}`,
|
||||
timestamp: index,
|
||||
provenance: "human_input",
|
||||
kind: "message",
|
||||
role: "user",
|
||||
content: [{ kind: "text", text: `question ${index}` }],
|
||||
},
|
||||
{
|
||||
entry_id: `assistant-${index}`,
|
||||
timestamp: index,
|
||||
provenance: "model_output",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
content: [{ kind: "text", text: `done ${index}` }],
|
||||
},
|
||||
],
|
||||
})),
|
||||
next_cursor: cursor,
|
||||
has_more: cursor !== null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotEvent(session = emptySession()): ProtocolEvent {
|
||||
return {
|
||||
event: "snapshot",
|
||||
@@ -367,18 +426,30 @@ test("applies retained empty and nonempty snapshots before showing their read-on
|
||||
snapshot: sessionWithUserMessage("retained message"),
|
||||
}));
|
||||
|
||||
expect(await screen.findByText("retained message")).not.toBeNull();
|
||||
expect(
|
||||
await within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.findByText("retained message"),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText("Read-only retained conversation")).not.toBeNull();
|
||||
expect(screen.queryByText("No conversation to display")).toBeNull();
|
||||
});
|
||||
|
||||
test("shows bounded initial failures and retries explicitly", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 503 }))
|
||||
.mockResolvedValueOnce(Response.json({
|
||||
availability: "retained_snapshot",
|
||||
snapshot: emptySession(),
|
||||
}));
|
||||
let sessionRequests = 0;
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
if (String(input).includes("/session/history")) {
|
||||
return Promise.resolve(Response.json(emptyHistoryPage()));
|
||||
}
|
||||
sessionRequests += 1;
|
||||
return Promise.resolve(
|
||||
sessionRequests === 1
|
||||
? new Response(null, { status: 503 })
|
||||
: Response.json({
|
||||
availability: "retained_snapshot",
|
||||
snapshot: emptySession(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(ConsolePage, { data: pageData() });
|
||||
|
||||
@@ -388,7 +459,7 @@ test("shows bounded initial failures and retries explicitly", async () => {
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(await screen.findByText("No conversation to display")).not.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(sessionRequests).toBe(2);
|
||||
});
|
||||
|
||||
test("distinguishes typed unavailability and an initial live connection closure", async () => {
|
||||
@@ -426,17 +497,165 @@ test("preserves rendered content while reconnecting", async () => {
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("kept message")),
|
||||
);
|
||||
expect(await screen.findByText("kept message")).not.toBeNull();
|
||||
expect(
|
||||
await within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.findByText("kept message"),
|
||||
).not.toBeNull();
|
||||
|
||||
latestListener().onStatus?.("closed", "Workspace subscription disconnected");
|
||||
expect(await screen.findByText("Conversation updates are unavailable")).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("kept message")).not.toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" })).getByText(
|
||||
"kept message",
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByText("No conversation to display")).toBeNull();
|
||||
|
||||
latestListener().onStatus?.("connecting", "resubscribing");
|
||||
expect(await screen.findByText("Refreshing conversation")).not.toBeNull();
|
||||
expect(screen.getByText("kept message")).not.toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" })).getByText(
|
||||
"kept message",
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test("reconnect snapshot refreshes lineage and removes stale sibling history", async () => {
|
||||
let historyRequests = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
if (!String(input).includes("/session/history")) {
|
||||
return Promise.resolve(
|
||||
Response.json({ availability: "live_protocol" }),
|
||||
);
|
||||
}
|
||||
historyRequests += 1;
|
||||
const response = historyPage([historyRequests], null);
|
||||
if (historyRequests === 2) {
|
||||
response.page.lineage_id = "lineage-b";
|
||||
}
|
||||
return Promise.resolve(Response.json(response));
|
||||
}),
|
||||
);
|
||||
|
||||
render(ConsolePage, { data: pageData() });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("lineage A current")),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expectHistoryTurn("question 1")
|
||||
);
|
||||
|
||||
latestListener().onStatus?.("closed", "disconnected during rewind");
|
||||
latestListener().onStatus?.("connecting", "reconnecting");
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("lineage B current")),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expectHistoryTurn("question 2")
|
||||
);
|
||||
expect(screen.queryByText("question 1")).toBeNull();
|
||||
expect(historyRequests).toBe(2);
|
||||
});
|
||||
|
||||
test("snapshot supersedes an in-flight page before stale lineage can render", async () => {
|
||||
const staleHistory = deferred<Response>();
|
||||
let historyRequests = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
if (!String(input).includes("/session/history")) {
|
||||
return Promise.resolve(
|
||||
Response.json({ availability: "live_protocol" }),
|
||||
);
|
||||
}
|
||||
historyRequests += 1;
|
||||
if (historyRequests === 1) return staleHistory.promise;
|
||||
const response = historyPage([2], null);
|
||||
response.page.lineage_id = "lineage-b";
|
||||
return Promise.resolve(Response.json(response));
|
||||
}),
|
||||
);
|
||||
|
||||
render(ConsolePage, { data: pageData() });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("lineage A current")),
|
||||
);
|
||||
await waitFor(() => expect(historyRequests).toBe(1));
|
||||
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("lineage B current")),
|
||||
);
|
||||
staleHistory.resolve(Response.json(historyPage([1], null)));
|
||||
|
||||
await waitFor(() =>
|
||||
expectHistoryTurn("question 2")
|
||||
);
|
||||
expect(screen.queryByText("question 1")).toBeNull();
|
||||
expect(historyRequests).toBe(2);
|
||||
});
|
||||
|
||||
test("fork refresh reconciles an in-flight older page to the adopted prefix", async () => {
|
||||
const staleOlder = deferred<Response>();
|
||||
let historyRequests = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
if (!String(input).includes("/session/history")) {
|
||||
return Promise.resolve(
|
||||
Response.json({ availability: "live_protocol" }),
|
||||
);
|
||||
}
|
||||
historyRequests += 1;
|
||||
if (historyRequests === 1) {
|
||||
return Promise.resolve(
|
||||
Response.json(historyPage([46, 47, 48, 49, 50], "older-46")),
|
||||
);
|
||||
}
|
||||
if (historyRequests === 2) return staleOlder.promise;
|
||||
return Promise.resolve(Response.json(historyPage(
|
||||
[101, 102, 103, 104, 105],
|
||||
"older-101",
|
||||
"lineage-fork",
|
||||
{
|
||||
lineage_id: "lineage-a",
|
||||
adopted_through_turn: historyPage([48], null).page
|
||||
.turns[0] as SessionConversationTurn,
|
||||
},
|
||||
)));
|
||||
}),
|
||||
);
|
||||
|
||||
render(ConsolePage, { data: pageData() });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
latestListener().onFrame(subscribedFrame());
|
||||
await waitFor(() =>
|
||||
expectHistoryTurn("question 50")
|
||||
);
|
||||
|
||||
const transcriptScroller = screen.getByLabelText("main transcript")
|
||||
.parentElement as HTMLElement;
|
||||
transcriptScroller.scrollTop = 0;
|
||||
await fireEvent.scroll(transcriptScroller);
|
||||
await waitFor(() => expect(historyRequests).toBe(2));
|
||||
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("fork current")),
|
||||
);
|
||||
staleOlder.resolve(Response.json(historyPage([41, 42, 43, 44, 45], null)));
|
||||
|
||||
await waitFor(() =>
|
||||
expectHistoryTurn("question 101")
|
||||
);
|
||||
expectHistoryTurn("question 48");
|
||||
expect(screen.queryByText("question 49")).toBeNull();
|
||||
expectHistoryTurn("question 41");
|
||||
expect(historyRequests).toBe(3);
|
||||
});
|
||||
|
||||
test("route changes clear prior content and fence stale Session responses", async () => {
|
||||
@@ -444,6 +663,9 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
const current = deferred<Response>();
|
||||
let firstWorkerLoads = 0;
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
if (String(input).includes("/session/history")) {
|
||||
return Promise.resolve(Response.json(emptyHistoryPage()));
|
||||
}
|
||||
if (String(input).includes("worker-b")) {
|
||||
return current.promise.then((value) => value.clone());
|
||||
}
|
||||
@@ -459,7 +681,10 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("previous worker content")),
|
||||
);
|
||||
expect(await screen.findByText("previous worker content")).not.toBeNull();
|
||||
expect(
|
||||
await within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.findByText("previous worker content"),
|
||||
).not.toBeNull();
|
||||
|
||||
latestListener().onStatus?.("closed", "reload old worker");
|
||||
await fireEvent.click(
|
||||
@@ -467,7 +692,10 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
);
|
||||
await waitFor(() => expect(firstWorkerLoads).toBe(2));
|
||||
await view.rerender({ data: pageData("worker-b", "runtime-b") });
|
||||
expect(screen.queryByText("previous worker content")).toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.queryByText("previous worker content"),
|
||||
).toBeNull();
|
||||
expect(screen.getByRole("status").textContent).toContain(
|
||||
"Loading conversation",
|
||||
);
|
||||
@@ -476,7 +704,12 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
availability: "retained_snapshot",
|
||||
snapshot: sessionWithUserMessage("current worker content"),
|
||||
}));
|
||||
expect(await screen.findByText("current worker content")).not.toBeNull();
|
||||
expect(
|
||||
await within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.findByText(
|
||||
"current worker content",
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
staleReload.resolve(Response.json({
|
||||
availability: "unavailable",
|
||||
@@ -484,6 +717,213 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
}));
|
||||
await settleMicrotasks();
|
||||
expect(screen.queryByText("stale worker error")).toBeNull();
|
||||
expect(screen.queryByText("previous worker content")).toBeNull();
|
||||
expect(screen.getByText("current worker content")).not.toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.queryByText("previous worker content"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" })).getByText(
|
||||
"current worker content",
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test("loads retained conversation turns five at a time into the log and turn bar", async () => {
|
||||
const historyPages = [
|
||||
{
|
||||
availability: "page",
|
||||
page: {
|
||||
session_id: "session-a",
|
||||
lineage_id: "lineage-a",
|
||||
compact_ancestor_lineage_ids: [],
|
||||
turns: [8, 9, 10, 11, 12].map((index) => ({
|
||||
turn_id: `user-${index}`,
|
||||
entries: [
|
||||
{
|
||||
entry_id: `user-${index}`,
|
||||
timestamp: index,
|
||||
provenance: "human_input",
|
||||
kind: "message",
|
||||
role: "user",
|
||||
content: [{ kind: "text", text: `question ${index}` }],
|
||||
},
|
||||
{
|
||||
entry_id: `assistant-${index}`,
|
||||
timestamp: index,
|
||||
provenance: "model_output",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
content: [{ kind: "text", text: `done ${index}` }],
|
||||
},
|
||||
],
|
||||
})),
|
||||
next_cursor: "older-8",
|
||||
has_more: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
availability: "page",
|
||||
page: {
|
||||
session_id: "session-a",
|
||||
lineage_id: "lineage-a",
|
||||
compact_ancestor_lineage_ids: [],
|
||||
turns: [3, 4, 5, 6, 7].map((index) => ({
|
||||
turn_id: `user-${index}`,
|
||||
entries: [{
|
||||
entry_id: `user-${index}`,
|
||||
timestamp: index,
|
||||
provenance: "human_input",
|
||||
kind: "message",
|
||||
role: "user",
|
||||
content: [{ kind: "text", text: `question ${index}` }],
|
||||
}],
|
||||
})),
|
||||
next_cursor: "older-3",
|
||||
has_more: true,
|
||||
},
|
||||
},
|
||||
historyPage([1, 2], null),
|
||||
];
|
||||
let historyRequests = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
if (String(input).includes("/session/history")) {
|
||||
return Promise.resolve(Response.json(historyPages[historyRequests++]));
|
||||
}
|
||||
return Promise.resolve(Response.json({ availability: "live_protocol" }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(ConsolePage, { data: pageData() });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
latestListener().onFrame(subscribedFrame());
|
||||
|
||||
await waitFor(() =>
|
||||
expectHistoryTurn("question 8")
|
||||
);
|
||||
expect(screen.getAllByText("done 12")).toHaveLength(1);
|
||||
const transcriptScroller = screen.getByLabelText("main transcript")
|
||||
.parentElement as HTMLElement;
|
||||
transcriptScroller.scrollTop = 0;
|
||||
await fireEvent.scroll(transcriptScroller);
|
||||
await waitFor(() =>
|
||||
expectHistoryTurn("question 3")
|
||||
);
|
||||
expect(historyRequests).toBe(2);
|
||||
|
||||
const navigation = screen.getByLabelText("Conversation turns");
|
||||
const navigationList = navigation.firstElementChild as HTMLElement;
|
||||
navigationList.scrollTop = 0;
|
||||
await fireEvent.scroll(navigationList);
|
||||
await fireEvent.scroll(navigationList);
|
||||
transcriptScroller.scrollTop = 0;
|
||||
await fireEvent.scroll(transcriptScroller);
|
||||
await settleMicrotasks();
|
||||
expect(historyRequests).toBe(2);
|
||||
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Earlier conversation available · load 5 turns",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expectHistoryTurn("question 1")
|
||||
);
|
||||
expect(screen.getAllByText("Start of conversation")).toHaveLength(1);
|
||||
expect(historyRequests).toBe(3);
|
||||
|
||||
const target = screen.getByText("question 8").closest(".console-line") as HTMLElement;
|
||||
vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ top: 600 } as DOMRect);
|
||||
vi.spyOn(transcriptScroller, "getBoundingClientRect").mockReturnValue({ top: 200 } as DOMRect);
|
||||
Object.defineProperties(transcriptScroller, {
|
||||
scrollHeight: { configurable: true, value: 2000 },
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
});
|
||||
transcriptScroller.scrollTop = 400;
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: /^Turn \d+: question 8$/,
|
||||
}),
|
||||
);
|
||||
expect(transcriptScroller.scrollTop).toBe(800);
|
||||
});
|
||||
|
||||
test("keeps live content when history fails and retries the bounded page explicitly", async () => {
|
||||
let historyRequests = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
if (String(input).includes("/session/history")) {
|
||||
historyRequests += 1;
|
||||
return Promise.resolve(
|
||||
historyRequests === 1
|
||||
? new Response(null, { status: 503 })
|
||||
: Response.json(historyPage([1, 2, 3, 4], null)),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(Response.json({ availability: "live_protocol" }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(ConsolePage, { data: pageData() });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("current response remains")),
|
||||
);
|
||||
|
||||
expect(
|
||||
await within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.findByText("current response remains"),
|
||||
).not.toBeNull();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "Earlier conversation unavailable · retry",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expectHistoryTurn("question 1")
|
||||
);
|
||||
expect(historyRequests).toBe(2);
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" })).getByText(
|
||||
"current response remains",
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(screen.getAllByText("Start of conversation")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("fences delayed history pages when the selected Worker route changes", async () => {
|
||||
const stale = deferred<Response>();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const path = String(input);
|
||||
if (path.includes("/session/history")) {
|
||||
return path.includes("worker-b")
|
||||
? Promise.resolve(Response.json(historyPage([21], null)))
|
||||
: stale.promise;
|
||||
}
|
||||
return Promise.resolve(Response.json({
|
||||
availability: "retained_snapshot",
|
||||
snapshot: emptySession(),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
const view = render(ConsolePage, { data: pageData() });
|
||||
await view.rerender({ data: pageData("worker-b", "runtime-b") });
|
||||
await waitFor(() =>
|
||||
expectHistoryTurn("question 21")
|
||||
);
|
||||
|
||||
stale.resolve(Response.json(historyPage([99], null)));
|
||||
await settleMicrotasks();
|
||||
expect(screen.queryByText("question 99")).toBeNull();
|
||||
expectHistoryTurn("question 21");
|
||||
});
|
||||
|
||||
function expectHistoryTurn(text: string) {
|
||||
expect(screen.getAllByText(text)).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: new RegExp("^Turn \\d+: " + text + "$") })).not.toBeNull();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
parseWorkerLaunchOptionsResponse,
|
||||
} from '$lib/workspace/api/workers';
|
||||
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
|
||||
import { formatWorkdirPermissions } from '$lib/workspace/settings/workdir-permissions';
|
||||
import { formatExternalGrantPermissionLevel } from '$lib/workspace/settings/workdir-permissions';
|
||||
import {
|
||||
buildCreateWorkspaceWorkerRequest,
|
||||
defaultWorkerLaunchForm,
|
||||
@@ -40,7 +40,7 @@
|
||||
: null;
|
||||
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
|
||||
if (directory.source.kind === 'external_grant') {
|
||||
return `${label} · External · ${formatWorkdirPermissions(directory.source.grant_permissions)}`;
|
||||
return `${label} · External · ${formatExternalGrantPermissionLevel(directory.source.grant_permissions)}`;
|
||||
}
|
||||
return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
|
||||
source: {
|
||||
kind: "external_grant",
|
||||
grant_id: "grant-1",
|
||||
grant_permissions: { read: true, write: false, command: true },
|
||||
grant_permissions: { read: true, write: true, command: true },
|
||||
},
|
||||
materializer_kind: "client_hosted_external",
|
||||
status: "active",
|
||||
@@ -72,7 +72,7 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
|
||||
}
|
||||
if (
|
||||
!detail.item.source.grant_permissions.read ||
|
||||
detail.item.source.grant_permissions.write ||
|
||||
!detail.item.source.grant_permissions.write ||
|
||||
!detail.item.source.grant_permissions.command
|
||||
) {
|
||||
throw new Error("External Workdir grant permissions were not preserved");
|
||||
@@ -83,6 +83,11 @@ Deno.test("Workdir REST validation rejects missing or misplaced External grant p
|
||||
for (
|
||||
const source of [
|
||||
{ kind: "external_grant", grant_id: "grant-1" },
|
||||
{
|
||||
kind: "external_grant",
|
||||
grant_id: "grant-1",
|
||||
grant_permissions: { read: false, write: false, command: true },
|
||||
},
|
||||
{
|
||||
kind: "external_grant",
|
||||
grant_id: "grant-1",
|
||||
@@ -113,7 +118,11 @@ Deno.test("Workdir REST validation rejects missing or misplaced External grant p
|
||||
rejected = true;
|
||||
}
|
||||
if (!rejected) {
|
||||
throw new Error(`invalid External permission projection was accepted: ${JSON.stringify(source)}`);
|
||||
throw new Error(
|
||||
`invalid External permission projection was accepted: ${
|
||||
JSON.stringify(source)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,9 +2,12 @@ declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import { formatWorkdirPermissions } from "../src/lib/workspace/settings/workdir-permissions.ts";
|
||||
import {
|
||||
formatExternalGrantPermissionLevel,
|
||||
formatWorkdirPermissions,
|
||||
} from "../src/lib/workspace/settings/workdir-permissions.ts";
|
||||
|
||||
Deno.test("Workdir permissions render independent categories in stable order", () => {
|
||||
Deno.test("effective Workdir permissions render partial intersections without widening", () => {
|
||||
const cases = [
|
||||
[{ read: true, write: false, command: false }, "READ"],
|
||||
[{ read: true, write: true, command: false }, "READ · WRITE"],
|
||||
@@ -20,3 +23,32 @@ Deno.test("Workdir permissions render independent categories in stable order", (
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("External grant levels show inherited authority and reject partial grants", () => {
|
||||
const cases = [
|
||||
[{ read: true, write: false, command: false }, "READ"],
|
||||
[{ read: true, write: true, command: false }, "WRITE (includes READ)"],
|
||||
[
|
||||
{ read: true, write: true, command: true },
|
||||
"COMMAND (includes WRITE + READ)",
|
||||
],
|
||||
] as const;
|
||||
for (const [permissions, expected] of cases) {
|
||||
const actual = formatExternalGrantPermissionLevel(permissions);
|
||||
if (actual !== expected) {
|
||||
throw new Error(`expected ${expected}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
let rejected = false;
|
||||
try {
|
||||
formatExternalGrantPermissionLevel({
|
||||
read: false,
|
||||
write: false,
|
||||
command: true,
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
if (!rejected) throw new Error("command-only External grant was accepted");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user