style: format web workspace

This commit is contained in:
Keisuke Hirata 2026-07-24 17:54:16 +09:00
parent 1c2cb01882
commit 54bb7209a4
No known key found for this signature in database
32 changed files with 974 additions and 529 deletions

View File

@ -2,7 +2,8 @@
SvelteKit static SPA for the Yoi workspace control plane. SvelteKit static SPA for the Yoi workspace control plane.
The frontend is intentionally static. Workspace authority, validation, and API behavior live in the Rust `yoi-workspace-server` backend. The frontend is intentionally static. Workspace authority, validation, and API
behavior live in the Rust `yoi-workspace-server` backend.
## Development ## Development
@ -22,7 +23,9 @@ cd web/workspace
deno task dev deno task dev
``` ```
The Vite dev server proxies `/api/*` to `http://127.0.0.1:8787`, so frontend hot reload works while the Rust backend serves the workspace API. Open the Vite URL printed by `deno task dev`. The Vite dev server proxies `/api/*` to `http://127.0.0.1:8787`, so frontend hot
reload works while the Rust backend serves the workspace API. Open the Vite URL
printed by `deno task dev`.
If you want to run the backend from the repository root instead: If you want to run the backend from the repository root instead:
@ -30,7 +33,10 @@ If you want to run the backend from the repository root instead:
cargo run -p yoi-workspace-server -- serve --listen 127.0.0.1:8787 cargo run -p yoi-workspace-server -- serve --listen 127.0.0.1:8787
``` ```
The backend reads Workspace records from the Yoi server DB at `<data_dir>/server/server.db`. Run `cargo run -p yoi-workspace-server -- init --workspace .` first when the server DB has not been initialized. The backend reads Workspace records from the Yoi server DB at
`<data_dir>/server/server.db`. Run
`cargo run -p yoi-workspace-server -- init --workspace .` first when the server
DB has not been initialized.
## Static build ## Static build
@ -40,7 +46,8 @@ Build the SPA:
deno task build deno task build
``` ```
Static asset packaging is a deployment concern. Local development normally uses the Vite dev server proxy plus the Rust backend command above. Static asset packaging is a deployment concern. Local development normally uses
the Vite dev server proxy plus the Rust backend command above.
## Checks ## Checks

View File

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />

View File

@ -12,112 +12,294 @@ export type WorkerStatus = "idle" | "running" | "paused";
export type TurnResult = "finished" | "paused"; export type TurnResult = "finished" | "paused";
export type InvokeKind = "user_send" | "notify" | "worker_event" | "system_reminder" | "wakeup"; export type InvokeKind =
| "user_send"
| "notify"
| "worker_event"
| "system_reminder"
| "wakeup";
export type RunResult = "finished" | "paused" | "limit_reached" | "rolled_back"; export type RunResult = "finished" | "paused" | "limit_reached" | "rolled_back";
export type ErrorCode = "already_running" | "not_running" | "not_paused" | "provider_error" | "tool_error" | "invalid_request" | "internal"; export type ErrorCode =
| "already_running"
| "not_running"
| "not_paused"
| "provider_error"
| "tool_error"
| "invalid_request"
| "internal";
export type Permission = "read" | "write"; export type Permission = "read" | "write";
export type InFlightToolCallState = "pending" | "streaming_args" | "done"; export type InFlightToolCallState = "pending" | "streaming_args" | "done";
export type ScopeRule = { export type ScopeRule = {
/** /**
* Target path. Must be absolute by the time a `Scope` is built from * Target path. Must be absolute by the time a `Scope` is built from
* this rule relative paths are resolved per-layer against the * this rule relative paths are resolved per-layer against the
* manifest file's directory (cwd for overlay layers) before cascade * manifest file's directory (cwd for overlay layers) before cascade
* merge. * merge.
*/ */
target: string, target: string;
/** /**
* Permission level this rule grants (allow) or caps strictly below * Permission level this rule grants (allow) or caps strictly below
* (deny). * (deny).
*/ */
permission: Permission, permission: Permission;
/** /**
* When `false`, the rule only matches the target itself and its * When `false`, the rule only matches the target itself and its
* direct children. Defaults to `true`. * direct children. Defaults to `true`.
*/ */
recursive: boolean, }; recursive: boolean;
};
export type CompletionEntry = { value: string, is_dir: boolean, }; export type CompletionEntry = { value: string; is_dir: boolean };
export type RewindTargetId = { segment_id: string, user_input_entry_index: number, }; export type RewindTargetId = {
segment_id: string;
user_input_entry_index: number;
};
export type RewindTarget = { id: RewindTargetId, expected_head_entries: number, truncate_entries: number, turn_index: number, timestamp_ms: number | null, preview: string, eligible: boolean, disabled_reason: string | null, warning: string | null, }; export type RewindTarget = {
id: RewindTargetId;
expected_head_entries: number;
truncate_entries: number;
turn_index: number;
timestamp_ms: number | null;
preview: string;
eligible: boolean;
disabled_reason: string | null;
warning: string | null;
};
export type RewindSummary = { truncated_to_entries: number, discarded_entries: number, tool_side_effect_warning: boolean, }; export type RewindSummary = {
truncated_to_entries: number;
discarded_entries: number;
tool_side_effect_warning: boolean;
};
export type InFlightBlock = { "kind": "text", text: string, finished?: boolean, } | { "kind": "thinking", text: string, finished?: boolean, } | { "kind": "tool_call", id: string, name: string, args: string, state?: InFlightToolCallState, }; export type InFlightBlock =
| { "kind": "text"; text: string; finished?: boolean }
| { "kind": "thinking"; text: string; finished?: boolean }
| {
"kind": "tool_call";
id: string;
name: string;
args: string;
state?: InFlightToolCallState;
};
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, }; export type InFlightSnapshot = { blocks?: Array<InFlightBlock> };
export type Greeting = { worker_name: string, cwd: string, provider: string, model: string, scope_summary: string, tools: Array<string>, export type Greeting = {
/** worker_name: string;
* Model context window in tokens. Always filled by the Worker greeting. cwd: string;
*/ provider: string;
context_window: number, model: string;
/** scope_summary: string;
* Estimated current session context tokens at connect time. tools: Array<string>;
*/ /**
context_tokens: number, }; * Model context window in tokens. Always filled by the Worker greeting.
*/
context_window: number;
/**
* Estimated current session context tokens at connect time.
*/
context_tokens: number;
};
export type Alert = { level: AlertLevel, source: AlertSource, message: string, export type Alert = {
/** level: AlertLevel;
* Milliseconds since the Unix epoch. source: AlertSource;
*/ message: string;
timestamp_ms: number, }; /**
* Milliseconds since the Unix epoch.
*/
timestamp_ms: number;
};
export type MemoryWorkerEvent = { worker: string, status: string, run_id: string, trigger: string, reason: string, export type MemoryWorkerEvent = {
/** worker: string;
* Human-readable compact form for actionbar rendering. status: string;
*/ run_id: string;
message: string, trigger: string;
/** reason: string;
* Milliseconds since the Unix epoch. /**
*/ * Human-readable compact form for actionbar rendering.
timestamp_ms: number, }; */
message: string;
/**
* Milliseconds since the Unix epoch.
*/
timestamp_ms: number;
};
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "file_ref", path: string, } | { "kind": "unknown" }; export type Segment =
| { "kind": "text"; content: string }
| {
"kind": "paste";
id: number;
chars: number;
lines: number;
content: string;
}
| { "kind": "file_ref"; path: string }
| { "kind": "unknown" };
export type WorkerEvent = { "kind": "turn_ended", worker_name: string, } | { "kind": "errored", worker_name: string, message: string, } | { "kind": "shut_down", worker_name: string, } | { "kind": "scope_sub_delegated", export type WorkerEvent =
/** | { "kind": "turn_ended"; worker_name: string }
* Sub-delegating Worker (= the sender itself). | { "kind": "errored"; worker_name: string; message: string }
*/ | { "kind": "shut_down"; worker_name: string }
parent_worker: string, | {
/** "kind": "scope_sub_delegated";
* Name of the grandchild Worker. /**
*/ * Sub-delegating Worker (= the sender itself).
sub_worker: string, */
/** parent_worker: string;
* Unix-socket path where the grandchild is reachable. /**
*/ * Name of the grandchild Worker.
sub_socket: string, */
/** sub_worker: string;
* Scope delegated to the grandchild. /**
*/ * Unix-socket path where the grandchild is reachable.
scope: Array<ScopeRule>, }; */
sub_socket: string;
/**
* Scope delegated to the grandchild.
*/
scope: Array<ScopeRule>;
};
export type Method = { "method": "run", "params": { input: Array<Segment>, } } | { "method": "notify", "params": { message: string, auto_run?: boolean, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "resume" } | { "method": "cancel" } | { "method": "pause" } | { "method": "compact" } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown" } | { "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 Method =
| { "method": "run"; "params": { input: Array<Segment> } }
| { "method": "notify"; "params": { message: string; auto_run?: boolean } }
| { "method": "worker_event"; "params": WorkerEvent }
| { "method": "resume" }
| { "method": "cancel" }
| { "method": "pause" }
| { "method": "compact" }
| { "method": "list_rewind_targets" }
| {
"method": "rewind_to";
"params": { target: RewindTargetId; expected_head_entries: number };
}
| { "method": "shutdown" }
| {
"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": "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": "user_message"; "data": { segments: Array<Segment> } }
* The attempt that just failed. 1 origin. | { "event": "system_item"; "data": { item: unknown } }
*/ | { "event": "invoke_start"; "data": { kind: InvokeKind } }
failed_attempt: number, max_attempts: number, wait_ms: number, elapsed_ms: number, status?: number | null, error: string, } } | { "event": "llm_continuation", "data": { llm_call: number, attempt: number, max_attempts: number, reason: string, } } | { "event": "text_delta", "data": { text: string, } } | { "event": "text_done", "data": { text: string, } } | { "event": "thinking_start" } | { "event": "thinking_delta", "data": { text: string, } } | { "event": "thinking_done", "data": { text: string, } } | { "event": "tool_call_start", "data": { id: string, name: string, } } | { "event": "tool_call_args_delta", "data": { id: string, json: string, } } | { "event": "tool_call_done", "data": { id: string, name: string, arguments: string, } } | { "event": "tool_result", "data": { id: string, | { "event": "turn_start"; "data": { turn: number } }
/** | { "event": "turn_end"; "data": { turn: number; result: TurnResult } }
* Short human-readable summary. Always present; used by clients | { "event": "llm_call_start"; "data": { llm_call: number } }
* that only want a 1-line rendering (e.g. collapsed views). | { "event": "llm_call_end"; "data": { llm_call: number } }
*/ | {
summary: string, "event": "llm_retry";
/** "data": {
* Full tool output. Absent when the tool chose to return llm_call: number;
* summary-only, or when the result was pruned. /**
*/ * The attempt that just failed. 1 origin.
output?: string | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { entries: Array<unknown>, greeting: Greeting, status: WorkerStatus, */
/** failed_attempt: number;
* Unfinished model output that has already streamed in the current max_attempts: number;
* run but is not yet represented by committed snapshot entries. wait_ms: number;
*/ elapsed_ms: number;
in_flight?: InFlightSnapshot, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" }; status?: number | null;
error: string;
};
}
| {
"event": "llm_continuation";
"data": {
llm_call: number;
attempt: number;
max_attempts: number;
reason: string;
};
}
| { "event": "text_delta"; "data": { text: string } }
| { "event": "text_done"; "data": { text: string } }
| { "event": "thinking_start" }
| { "event": "thinking_delta"; "data": { text: string } }
| { "event": "thinking_done"; "data": { text: string } }
| { "event": "tool_call_start"; "data": { id: string; name: string } }
| { "event": "tool_call_args_delta"; "data": { id: string; json: string } }
| {
"event": "tool_call_done";
"data": { id: string; name: string; arguments: string };
}
| {
"event": "tool_result";
"data": {
id: string;
/**
* Short human-readable summary. Always present; used by clients
* that only want a 1-line rendering (e.g. collapsed views).
*/
summary: string;
/**
* Full tool output. Absent when the tool chose to return
* summary-only, or when the result was pruned.
*/
output?: string | null;
is_error: boolean;
};
}
| {
"event": "usage";
"data": {
input_tokens: number | null;
output_tokens: number | null;
cache_read_input_tokens?: number | null;
};
}
| { "event": "run_end"; "data": { result: RunResult } }
| { "event": "error"; "data": { code: ErrorCode; message: string } }
| {
"event": "snapshot";
"data": {
entries: Array<unknown>;
greeting: Greeting;
status: WorkerStatus;
/**
* Unfinished model output that has already streamed in the current
* run but is not yet represented by committed snapshot entries.
*/
in_flight?: InFlightSnapshot;
};
}
| { "event": "segment_rotated"; "data": { entry: unknown } }
| { "event": "status"; "data": { status: WorkerStatus } }
| {
"event": "completions";
"data": { kind: CompletionKind; entries: Array<CompletionEntry> };
}
| {
"event": "rewind_targets";
"data": { head_entries: number; targets: Array<RewindTarget> };
}
| {
"event": "rewind_applied";
"data": {
entries: Array<unknown>;
input: Array<Segment>;
summary: RewindSummary;
};
}
| { "event": "workers_listed"; "data": { workers: unknown } }
| { "event": "worker_restored"; "data": { result: unknown } }
| { "event": "peer_registered"; "data": { result: unknown } }
| { "event": "alert"; "data": Alert }
| { "event": "memory_worker"; "data": MemoryWorkerEvent }
| { "event": "compact_start" }
| { "event": "compact_done"; "data": { new_segment_id: string } }
| { "event": "compact_failed"; "data": { error: string } }
| { "event": "shutdown" };

View File

@ -1,4 +1,10 @@
export type WorkspaceAlertLevel = "success" | "info" | "warning" | "error" | "system" | "debug"; export type WorkspaceAlertLevel =
| "success"
| "info"
| "warning"
| "error"
| "system"
| "debug";
export type WorkspaceAlert = { export type WorkspaceAlert = {
id: string; id: string;
@ -32,7 +38,8 @@ export function pushWorkspaceAlert(
message: string, message: string,
options: { title?: string; id?: string } = {}, options: { title?: string; id?: string } = {},
): string { ): string {
const id = options.id ?? `${Date.now().toString(36)}-${(sequence++).toString(36)}`; const id = options.id ??
`${Date.now().toString(36)}-${(sequence++).toString(36)}`;
alerts = [ alerts = [
...alerts.filter((alert) => alert.id !== id), ...alerts.filter((alert) => alert.id !== id),
{ {

View File

@ -1,20 +1,22 @@
import { import {
authenticationCredentialToJson, authenticationCredentialToJson,
isPublicKeyCredential,
prepareLoginOptions,
prepareRegistrationOptions,
registrationCredentialToJson,
type DeviceApprovalResponse, type DeviceApprovalResponse,
isPublicKeyCredential,
type PasskeyLoginOptionsResponse, type PasskeyLoginOptionsResponse,
type PasskeyRegistrationOptionsResponse, type PasskeyRegistrationOptionsResponse,
type PasskeyUserResponse, type PasskeyUserResponse,
prepareLoginOptions,
prepareRegistrationOptions,
registrationCredentialToJson,
type WhoamiResponse, type WhoamiResponse,
} from "./model"; } from "./model";
async function jsonOrThrow<T>(response: Response): Promise<T> { async function jsonOrThrow<T>(response: Response): Promise<T> {
const text = await response.text(); const text = await response.text();
if (!response.ok) { if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}${text ? `: ${text}` : ""}`); throw new Error(
`${response.status} ${response.statusText}${text ? `: ${text}` : ""}`,
);
} }
return text ? JSON.parse(text) as T : (null as T); return text ? JSON.parse(text) as T : (null as T);
} }
@ -23,8 +25,12 @@ function browserOrigin(): string | null {
return globalThis.location?.origin ?? null; return globalThis.location?.origin ?? null;
} }
export async function loadWhoami(fetcher: typeof fetch = fetch): Promise<WhoamiResponse> { export async function loadWhoami(
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(jsonOrThrow<WhoamiResponse>); fetcher: typeof fetch = fetch,
): Promise<WhoamiResponse> {
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(
jsonOrThrow<WhoamiResponse>,
);
} }
export async function registerPasskey( export async function registerPasskey(
@ -36,14 +42,20 @@ export async function registerPasskey(
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
credentials: "same-origin", credentials: "same-origin",
body: JSON.stringify({ handle, display_name: displayName, browser_origin: browserOrigin() }), body: JSON.stringify({
handle,
display_name: displayName,
browser_origin: browserOrigin(),
}),
}).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>); }).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>);
const credential = await navigator.credentials.create({ const credential = await navigator.credentials.create({
publicKey: prepareRegistrationOptions(options), publicKey: prepareRegistrationOptions(options),
}); });
if (!isPublicKeyCredential(credential)) { if (!isPublicKeyCredential(credential)) {
throw new Error("Passkey registration did not return a public-key credential."); throw new Error(
"Passkey registration did not return a public-key credential.",
);
} }
return await fetcher("/api/auth/passkeys/registration/complete", { return await fetcher("/api/auth/passkeys/registration/complete", {

View File

@ -11,7 +11,9 @@ declare const Deno: {
function assertEquals<T>(actual: T, expected: T): void { function assertEquals<T>(actual: T, expected: T): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) { if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); throw new Error(
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
} }
} }
@ -19,7 +21,9 @@ function bytes(buffer: BufferSource): number[] {
if (buffer instanceof ArrayBuffer) { if (buffer instanceof ArrayBuffer) {
return [...new Uint8Array(buffer)]; return [...new Uint8Array(buffer)];
} }
return [...new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)]; return [
...new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength),
];
} }
Deno.test("base64url helpers round-trip binary data", () => { Deno.test("base64url helpers round-trip binary data", () => {
@ -43,13 +47,20 @@ Deno.test("prepareRegistrationOptions decodes binary public key fields", () => {
displayName: "Local User", displayName: "Local User",
}, },
pubKeyCredParams: [{ type: "public-key", alg: -7 }], pubKeyCredParams: [{ type: "public-key", alg: -7 }],
excludeCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }], excludeCredentials: [{
type: "public-key",
id: "BwgJ" as unknown as BufferSource,
}],
}, },
}); });
assertEquals(bytes(options.challenge), [1, 2, 3]); assertEquals(bytes(options.challenge), [1, 2, 3]);
assertEquals(bytes(options.user.id), [4, 5, 6]); assertEquals(bytes(options.user.id), [4, 5, 6]);
assertEquals(bytes(options.excludeCredentials?.[0].id as BufferSource), [7, 8, 9]); assertEquals(bytes(options.excludeCredentials?.[0].id as BufferSource), [
7,
8,
9,
]);
}); });
Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () => { Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () => {
@ -57,10 +68,17 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
challenge_id: "challenge-1", challenge_id: "challenge-1",
public_key: { public_key: {
challenge: "AQID" as unknown as BufferSource, challenge: "AQID" as unknown as BufferSource,
allowCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }], allowCredentials: [{
type: "public-key",
id: "BwgJ" as unknown as BufferSource,
}],
}, },
}); });
assertEquals(bytes(options.challenge), [1, 2, 3]); assertEquals(bytes(options.challenge), [1, 2, 3]);
assertEquals(bytes(options.allowCredentials?.[0].id as BufferSource), [7, 8, 9]); assertEquals(bytes(options.allowCredentials?.[0].id as BufferSource), [
7,
8,
9,
]);
}); });

View File

@ -15,12 +15,16 @@ export type WhoamiResponse = {
export type PasskeyRegistrationOptionsResponse = { export type PasskeyRegistrationOptionsResponse = {
challenge_id: string; challenge_id: string;
public_key: PublicKeyCredentialCreationOptions | { publicKey: PublicKeyCredentialCreationOptions }; public_key: PublicKeyCredentialCreationOptions | {
publicKey: PublicKeyCredentialCreationOptions;
};
}; };
export type PasskeyLoginOptionsResponse = { export type PasskeyLoginOptionsResponse = {
challenge_id: string; challenge_id: string;
public_key: PublicKeyCredentialRequestOptions | { publicKey: PublicKeyCredentialRequestOptions }; public_key: PublicKeyCredentialRequestOptions | {
publicKey: PublicKeyCredentialRequestOptions;
};
}; };
export type PasskeyUserResponse = { export type PasskeyUserResponse = {
@ -65,7 +69,10 @@ export function bufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
let binary = ""; let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte); for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
/=+$/g,
"",
);
} }
function unwrapPublicKey<T>(value: T | { publicKey: T }): T { function unwrapPublicKey<T>(value: T | { publicKey: T }): T {
@ -79,12 +86,16 @@ export function prepareRegistrationOptions(
options: PasskeyRegistrationOptionsResponse, options: PasskeyRegistrationOptionsResponse,
): PublicKeyCredentialCreationOptions { ): PublicKeyCredentialCreationOptions {
const publicKey = structuredClone(unwrapPublicKey(options.public_key)); const publicKey = structuredClone(unwrapPublicKey(options.public_key));
publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string); publicKey.challenge = base64UrlToBuffer(
publicKey.challenge as unknown as string,
);
publicKey.user = { publicKey.user = {
...publicKey.user, ...publicKey.user,
id: base64UrlToBuffer(publicKey.user.id as unknown as string), id: base64UrlToBuffer(publicKey.user.id as unknown as string),
}; };
publicKey.excludeCredentials = publicKey.excludeCredentials?.map((credential) => ({ publicKey.excludeCredentials = publicKey.excludeCredentials?.map((
credential,
) => ({
...credential, ...credential,
id: base64UrlToBuffer(credential.id as unknown as string), id: base64UrlToBuffer(credential.id as unknown as string),
})); }));
@ -95,8 +106,12 @@ export function prepareLoginOptions(
options: PasskeyLoginOptionsResponse, options: PasskeyLoginOptionsResponse,
): PublicKeyCredentialRequestOptions { ): PublicKeyCredentialRequestOptions {
const publicKey = structuredClone(unwrapPublicKey(options.public_key)); const publicKey = structuredClone(unwrapPublicKey(options.public_key));
publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string); publicKey.challenge = base64UrlToBuffer(
publicKey.allowCredentials = publicKey.allowCredentials?.map((credential) => ({ publicKey.challenge as unknown as string,
);
publicKey.allowCredentials = publicKey.allowCredentials?.map((
credential,
) => ({
...credential, ...credential,
id: base64UrlToBuffer(credential.id as unknown as string), id: base64UrlToBuffer(credential.id as unknown as string),
})); }));
@ -131,11 +146,15 @@ export function authenticationCredentialToJson(
clientDataJSON: bufferToBase64Url(response.clientDataJSON), clientDataJSON: bufferToBase64Url(response.clientDataJSON),
authenticatorData: bufferToBase64Url(response.authenticatorData), authenticatorData: bufferToBase64Url(response.authenticatorData),
signature: bufferToBase64Url(response.signature), signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null, userHandle: response.userHandle
? bufferToBase64Url(response.userHandle)
: null,
}, },
}; };
} }
export function isPublicKeyCredential(credential: Credential | null): credential is PublicKeyCredential { export function isPublicKeyCredential(
credential: Credential | null,
): credential is PublicKeyCredential {
return credential != null && credential.type === "public-key"; return credential != null && credential.type === "public-key";
} }

View File

@ -53,9 +53,16 @@ Deno.test("shouldSubmitChatKey still supports explicit Cmd+Enter behavior", () =
}); });
Deno.test("shouldSubmitChatKey ignores IME composition and repeated Enter", () => { Deno.test("shouldSubmitChatKey ignores IME composition and repeated Enter", () => {
const options = { mode: "mod-enter" as const, modKey: "meta" as const, enabled: true }; const options = {
mode: "mod-enter" as const,
modKey: "meta" as const,
enabled: true,
};
assertEquals( assertEquals(
shouldSubmitChatKey({ key: "Enter", metaKey: true, isComposing: true }, options), shouldSubmitChatKey(
{ key: "Enter", metaKey: true, isComposing: true },
options,
),
false, false,
); );
assertEquals( assertEquals(
@ -69,7 +76,14 @@ Deno.test("shouldSubmitChatKey ignores IME composition and repeated Enter", () =
}); });
Deno.test("shouldSubmitChatKey supports enter submit mode", () => { Deno.test("shouldSubmitChatKey supports enter submit mode", () => {
const options = { mode: "enter" as const, modKey: "meta" as const, enabled: true }; const options = {
mode: "enter" as const,
modKey: "meta" as const,
enabled: true,
};
assert(shouldSubmitChatKey({ key: "Enter" }, options), "Enter should submit"); assert(shouldSubmitChatKey({ key: "Enter" }, options), "Enter should submit");
assertEquals(shouldSubmitChatKey({ key: "Enter", shiftKey: true }, options), false); assertEquals(
shouldSubmitChatKey({ key: "Enter", shiftKey: true }, options),
false,
);
}); });

View File

@ -23,7 +23,9 @@ export type ChatSubmitKeyEventLike = {
which?: number; which?: number;
}; };
function normalizeOptions(options: ChatSubmitOptions): NormalizedChatSubmitOptions { function normalizeOptions(
options: ChatSubmitOptions,
): NormalizedChatSubmitOptions {
return { return {
mode: "mod-enter", mode: "mod-enter",
modKey: "auto", modKey: "auto",
@ -47,7 +49,8 @@ function isApplePlatform(): boolean {
} }
const platform = navigator.platform || ""; const platform = navigator.platform || "";
const userAgent = navigator.userAgent || ""; const userAgent = navigator.userAgent || "";
return /Mac|iPhone|iPad|iPod/.test(platform) || /Mac|iPhone|iPad|iPod/.test(userAgent); return /Mac|iPhone|iPad|iPod/.test(platform) ||
/Mac|iPhone|iPad|iPod/.test(userAgent);
} }
function resolveModKey(modKey: ChatSubmitModKey): "meta" | "ctrl" { function resolveModKey(modKey: ChatSubmitModKey): "meta" | "ctrl" {
@ -57,8 +60,13 @@ function resolveModKey(modKey: ChatSubmitModKey): "meta" | "ctrl" {
return modKey; return modKey;
} }
function isModPressed(event: ChatSubmitKeyEventLike, modKey: ChatSubmitModKey): boolean { function isModPressed(
return resolveModKey(modKey) === "meta" ? event.metaKey === true : event.ctrlKey === true; event: ChatSubmitKeyEventLike,
modKey: ChatSubmitModKey,
): boolean {
return resolveModKey(modKey) === "meta"
? event.metaKey === true
: event.ctrlKey === true;
} }
export function shouldSubmitChatKey( export function shouldSubmitChatKey(
@ -79,7 +87,10 @@ export function shouldSubmitChatKey(
return isModPressed(event, options.modKey); return isModPressed(event, options.modKey);
} }
export function chatSubmit(node: HTMLTextAreaElement, options: ChatSubmitOptions) { export function chatSubmit(
node: HTMLTextAreaElement,
options: ChatSubmitOptions,
) {
let current = normalizeOptions(options); let current = normalizeOptions(options);
let isComposing = false; let isComposing = false;

View File

@ -16,5 +16,8 @@ Deno.test("parseSigilSegments turns file sigils into file refs", () => {
}); });
Deno.test("parseSigilSegments leaves hash sigils as plain text", () => { Deno.test("parseSigilSegments leaves hash sigils as plain text", () => {
assertEquals(parseSigilSegments("ask #memory"), [{ kind: "text", content: "ask #memory" }]); assertEquals(parseSigilSegments("ask #memory"), [{
kind: "text",
content: "ask #memory",
}]);
}); });

View File

@ -25,7 +25,8 @@ type CommandSpec = {
const COMMANDS: Record<string, CommandSpec> = { const COMMANDS: Record<string, CommandSpec> = {
help: { help: {
usage: ":help [command]", usage: ":help [command]",
description: "Show available Web Console commands or details for one command.", description:
"Show available Web Console commands or details for one command.",
}, },
"?": { "?": {
usage: ":? [command]", usage: ":? [command]",
@ -49,7 +50,8 @@ const COMMANDS: Record<string, CommandSpec> = {
}, },
peer: { peer: {
usage: ":peer <worker-name>", usage: ":peer <worker-name>",
description: "Register another existing Worker as a reciprocal metadata peer.", description:
"Register another existing Worker as a reciprocal metadata peer.",
}, },
system: { system: {
usage: ":system <message>", usage: ":system <message>",
@ -71,7 +73,9 @@ export function buildComposerRequest(value: string): ComposerCommandResult {
request: { request: {
kind: "user", kind: "user",
content, content,
segments: segments.some((segment) => segment.kind !== "text") ? segments : undefined, segments: segments.some((segment) => segment.kind !== "text")
? segments
: undefined,
}, },
}; };
} }
@ -97,13 +101,21 @@ function buildColonCommand(commandLine: string): ComposerCommandResult {
if (argv.length > 0) { if (argv.length > 0) {
return invalidUsage("compact"); return invalidUsage("compact");
} }
return { ok: true, request: { kind: "compact", content: "" }, notice: "compact requested" }; return {
ok: true,
request: { kind: "compact", content: "" },
notice: "compact requested",
};
case "rewind": case "rewind":
case "rollback": case "rollback":
if (argv.length > 0) { if (argv.length > 0) {
return invalidUsage("rewind"); return invalidUsage("rewind");
} }
return { ok: true, request: { kind: "list_rewind_targets", content: "" }, notice: "rewind targets requested" }; return {
ok: true,
request: { kind: "list_rewind_targets", content: "" },
notice: "rewind targets requested",
};
case "peer": case "peer":
if (argv.length !== 1) { if (argv.length !== 1) {
return invalidUsage("peer"); return invalidUsage("peer");
@ -156,7 +168,10 @@ function helpCommand(argv: string[]): ComposerCommandResult {
} }
function invalidUsage(name: string): ComposerCommandResult { function invalidUsage(name: string): ComposerCommandResult {
return { ok: false, message: `Invalid arguments. Usage: ${COMMANDS[name].usage}` }; return {
ok: false,
message: `Invalid arguments. Usage: ${COMMANDS[name].usage}`,
};
} }
export function parseSigilSegments(input: string): Segment[] { export function parseSigilSegments(input: string): Segment[] {
@ -178,7 +193,9 @@ export function parseSigilSegments(input: string): Segment[] {
if (cursor < input.length) { if (cursor < input.length) {
segments.push({ kind: "text", content: input.slice(cursor) }); segments.push({ kind: "text", content: input.slice(cursor) });
} }
return coalesceTextSegments(segments.length > 0 ? segments : [{ kind: "text", content: input }]); return coalesceTextSegments(
segments.length > 0 ? segments : [{ kind: "text", content: input }],
);
} }
function sigilSegment(sigil: string, value: string): Segment { function sigilSegment(sigil: string, value: string): Segment {

View File

@ -1,11 +1,11 @@
import type { Event } from "$lib/generated/protocol"; import type { Event } from "$lib/generated/protocol";
import { import {
type ConsoleLine,
createConsoleProjector, createConsoleProjector,
projectConsole, projectConsole,
segmentsToText, segmentsToText,
selectConsoleTimelineLines, selectConsoleTimelineLines,
workerConsoleHref, workerConsoleHref,
type ConsoleLine,
} from "./model.ts"; } from "./model.ts";
declare const Deno: { declare const Deno: {
@ -68,8 +68,6 @@ Deno.test("workerConsoleHref encodes runtime and worker target authority", () =>
); );
}); });
Deno.test("projectConsole projects visible protocol rows", () => { Deno.test("projectConsole projects visible protocol rows", () => {
const projection = projectConsole([ const projection = projectConsole([
{ {
@ -192,7 +190,8 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
data: { data: {
id: "call-1", id: "call-1",
summary: "command completed", summary: "command completed",
output: "/repo\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12", output:
"/repo\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12",
is_error: false, is_error: false,
}, },
} satisfies Event, } satisfies Event,
@ -269,7 +268,10 @@ Deno.test("projectConsole caps default tool request and result previews", () =>
assertEquals(line.title, "Call · CustomTool"); assertEquals(line.title, "Call · CustomTool");
assertEquals(line.body.split("\n").length, 7); assertEquals(line.body.split("\n").length, 7);
assert(line.body.includes("CustomTool — done"), "tool state should be shown"); assert(line.body.includes("CustomTool — done"), "tool state should be shown");
assert(line.body.includes('"first": "one"'), "request preview should be shown"); assert(
line.body.includes('"first": "one"'),
"request preview should be shown",
);
assert(line.body.includes("out1"), "result preview should be shown"); assert(line.body.includes("out1"), "result preview should be shown");
assert(!line.body.includes("third"), "request preview should be capped"); assert(!line.body.includes("third"), "request preview should be capped");
assert(!line.body.includes("out3"), "result preview should be capped"); assert(!line.body.includes("out3"), "result preview should be capped");
@ -305,7 +307,10 @@ Deno.test("projectConsole shows Grep query and caps result preview to five entri
const [line] = projection.lines.filter((line) => line.kind === "tool"); const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Grep"); assertEquals(line.title, "Call · Grep");
assert(line.body.includes("Grep — 6 matches"), "Grep summary should be shown"); assert(
line.body.includes("Grep — 6 matches"),
"Grep summary should be shown",
);
assert(line.body.includes("query: needle"), "Grep query should be shown"); assert(line.body.includes("query: needle"), "Grep query should be shown");
assert(line.body.includes("hit1"), "first result should be shown"); assert(line.body.includes("hit1"), "first result should be shown");
assert(line.body.includes("hit5"), "fifth result should be shown"); assert(line.body.includes("hit5"), "fifth result should be shown");
@ -840,7 +845,9 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
assertEquals(projection.status, "running"); assertEquals(projection.status, "running");
assertEquals( assertEquals(
projection.lines.map((line) => `${line.kind}:${line.body}:${line.streaming}`), projection.lines.map((line) =>
`${line.kind}:${line.body}:${line.streaming}`
),
[ [
"user:seed user:false", "user:seed user:false",
"user:new user:false", "user:new user:false",
@ -903,7 +910,9 @@ Deno.test("selectConsoleTimelineLines keeps all users and only last assistant pe
]; ];
assertEquals( assertEquals(
selectConsoleTimelineLines(items).map(({ item, index }) => `${index}:${item.id}`), selectConsoleTimelineLines(items).map(({ item, index }) =>
`${index}:${item.id}`
),
["0:u1", "3:a2", "4:u2", "6:a4"], ["0:u1", "3:a2", "4:u2", "6:a4"],
); );
}); });
@ -940,7 +949,10 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
data: { data: {
id: "write-rel", id: "write-rel",
name: "Write", name: "Write",
arguments: JSON.stringify({ file_path: "/repo/out.txt", content: "ok" }), arguments: JSON.stringify({
file_path: "/repo/out.txt",
content: "ok",
}),
}, },
} satisfies Event, } satisfies Event,
}, },
@ -1001,7 +1013,8 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
data: { data: {
id: "glob-rel", id: "glob-rel",
summary: "Found 2 file(s) matching **/*.rs", summary: "Found 2 file(s) matching **/*.rs",
output: "Found 2 file(s) matching **/*.rs\n/repo/src/main.rs\n/outside/lib.rs", output:
"Found 2 file(s) matching **/*.rs\n/repo/src/main.rs\n/outside/lib.rs",
is_error: false, is_error: false,
}, },
} satisfies Event, } satisfies Event,
@ -1031,14 +1044,18 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
}, },
]); ]);
const bodies = projection.lines.filter((line) => line.kind === "tool").map((line) => line.body); const bodies = projection.lines.filter((line) => line.kind === "tool").map((
line,
) => line.body);
assertEquals(bodies[0], "Read — 1 file read\n src/main.rs"); assertEquals(bodies[0], "Read — 1 file read\n src/main.rs");
assert( assert(
projection.lines[0].detail?.includes("from src/main.rs"), projection.lines[0].detail?.includes("from src/main.rs"),
"Read summary detail path should be relative", "Read summary detail path should be relative",
); );
assert( assert(
bodies.some((body) => body.includes("Write — out.txt") && body.includes("Wrote out.txt")), bodies.some((body) =>
body.includes("Write — out.txt") && body.includes("Wrote out.txt")
),
"Write header and known result path should be relative", "Write header and known result path should be relative",
); );
assert( assert(
@ -1055,7 +1072,8 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
); );
assert( assert(
bodies.some((body) => bodies.some((body) =>
body.includes("src/main.rs:12:needle") && body.includes("/outside/lib.rs:1:needle") body.includes("src/main.rs:12:needle") &&
body.includes("/outside/lib.rs:1:needle")
), ),
"Grep should relativize line-start cwd paths and keep outside paths absolute", "Grep should relativize line-start cwd paths and keep outside paths absolute",
); );

View File

@ -1,6 +1,6 @@
import type { import type {
Event as ProtocolEvent,
Alert, Alert,
Event as ProtocolEvent,
InFlightBlock, InFlightBlock,
InFlightToolCallState, InFlightToolCallState,
Segment, Segment,
@ -148,7 +148,10 @@ export function emptyConsoleProjection(): ConsoleProjection {
export function projectConsole( export function projectConsole(
events: ConsoleEventInput[] = [], events: ConsoleEventInput[] = [],
): ConsoleProjection { ): ConsoleProjection {
const projection = events.reduce(applyProtocolEvent, emptyConsoleProjection()); const projection = events.reduce(
applyProtocolEvent,
emptyConsoleProjection(),
);
return projectVisibleConsole(projection); return projectVisibleConsole(projection);
} }
@ -171,7 +174,9 @@ export function createConsoleProjector() {
}; };
} }
function projectVisibleConsole(projection: ConsoleProjection): ConsoleProjection { function projectVisibleConsole(
projection: ConsoleProjection,
): ConsoleProjection {
return { return {
...projection, ...projection,
lines: aggregateReadToolLines(projection.lines), lines: aggregateReadToolLines(projection.lines),
@ -302,7 +307,9 @@ export function applyProtocolEvent(
next.status = event.data.status; next.status = event.data.status;
break; break;
case "segment_rotated": case "segment_rotated":
next.lines = snapshotLinesFromEntries(envelope.eventId, [event.data.entry], next.cwd); next.lines = snapshotLinesFromEntries(envelope.eventId, [
event.data.entry,
], next.cwd);
break; break;
case "invoke_start": case "invoke_start":
case "turn_start": case "turn_start":
@ -730,7 +737,9 @@ function readDetail(toolCall: ToolCallView): string {
`state: ${stateSuffix(toolCall.state)}`, `state: ${stateSuffix(toolCall.state)}`,
`path: ${readPath(toolCall)}`, `path: ${readPath(toolCall)}`,
toolCall.summary toolCall.summary
? `summary: ${normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)}` ? `summary: ${
normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)
}`
: undefined, : undefined,
]); ]);
} }
@ -902,7 +911,9 @@ function toolCallDetail(toolCall: ToolCallView): string {
`id: ${toolCall.id}`, `id: ${toolCall.id}`,
`state: ${stateSuffix(toolCall.state)}`, `state: ${stateSuffix(toolCall.state)}`,
toolCall.summary toolCall.summary
? `summary: ${normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)}` ? `summary: ${
normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)
}`
: undefined, : undefined,
argsText(toolCall) ? `arguments:\n${argsText(toolCall)}` : undefined, argsText(toolCall) ? `arguments:\n${argsText(toolCall)}` : undefined,
]); ]);
@ -1155,7 +1166,8 @@ function applyExtensionEntry(
} }
const blockId = stringField(payload, "block_id") || "compact"; const blockId = stringField(payload, "block_id") || "compact";
const state = stringField(payload, "state") || "running"; const state = stringField(payload, "state") || "running";
const message = stringField(payload, "message") || compactMessageForState(state, payload); const message = stringField(payload, "message") ||
compactMessageForState(state, payload);
upsertStatusLine( upsertStatusLine(
projection, projection,
blockId, blockId,
@ -1204,26 +1216,38 @@ function applyLoggedItem(
break; break;
} }
case "reasoning": { case "reasoning": {
const text = stringField(item, "text") ?? arrayField(item, "summary").filter((value) => typeof value === "string").join("\n"); const text = stringField(item, "text") ??
arrayField(item, "summary").filter((value) => typeof value === "string")
.join("\n");
if (text) { if (text) {
projection.lines.push(line(eventId, "thinking", "Thought", text)); projection.lines.push(line(eventId, "thinking", "Thought", text));
} }
break; break;
} }
case "tool_call": case "tool_call":
upsertToolCall(projection, eventId, stringField(item, "call_id") ?? eventId, { upsertToolCall(
name: stringField(item, "name") ?? "Tool", projection,
arguments: stringField(item, "arguments") ?? "", eventId,
argsStream: stringField(item, "arguments") ?? "", stringField(item, "call_id") ?? eventId,
state: "running", {
}); name: stringField(item, "name") ?? "Tool",
arguments: stringField(item, "arguments") ?? "",
argsStream: stringField(item, "arguments") ?? "",
state: "running",
},
);
break; break;
case "tool_result": case "tool_result":
attachToolResult(projection, eventId, stringField(item, "call_id") ?? eventId, { attachToolResult(
summary: stringField(item, "summary") ?? "", projection,
output: stringField(item, "content"), eventId,
isError: item["is_error"] === true, stringField(item, "call_id") ?? eventId,
}); {
summary: stringField(item, "summary") ?? "",
output: stringField(item, "content"),
isError: item["is_error"] === true,
},
);
break; break;
default: default:
break; break;

View File

@ -10,7 +10,9 @@ function assert(condition: unknown, message: string): asserts condition {
} }
Deno.test("workspace app css uses bundled UI fonts", async () => { Deno.test("workspace app css uses bundled UI fonts", async () => {
const appCss = await Deno.readTextFile(new URL("./../../../app.css", import.meta.url)); const appCss = await Deno.readTextFile(
new URL("./../../../app.css", import.meta.url),
);
assert( assert(
appCss.includes("gen-interface-jp/400.css") && appCss.includes("gen-interface-jp/400.css") &&
appCss.includes("gen-interface-jp/500.css") && appCss.includes("gen-interface-jp/500.css") &&
@ -34,12 +36,20 @@ Deno.test("workspace app css uses bundled UI fonts", async () => {
}); });
Deno.test("workspace feature css is owned outside app css", async () => { Deno.test("workspace feature css is owned outside app css", async () => {
const appCss = await Deno.readTextFile(new URL("./../../../app.css", import.meta.url)); const appCss = await Deno.readTextFile(
new URL("./../../../app.css", import.meta.url),
);
const workspaceLayout = await Deno.readTextFile( const workspaceLayout = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url), new URL(
"./../../../routes/w/[workspaceId]/+layout.svelte",
import.meta.url,
),
); );
const settingsLayout = await Deno.readTextFile( const settingsLayout = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/settings/+layout.svelte", import.meta.url), new URL(
"./../../../routes/w/[workspaceId]/settings/+layout.svelte",
import.meta.url,
),
); );
const accountPage = await Deno.readTextFile( const accountPage = await Deno.readTextFile(
new URL("./../../../routes/account/+page.svelte", import.meta.url), new URL("./../../../routes/account/+page.svelte", import.meta.url),
@ -86,7 +96,9 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
workspacePage.includes("runtimeSettingsHref") && workspacePage.includes("runtimeSettingsHref") &&
workspacePage.includes("workersHref") && workspacePage.includes("workersHref") &&
workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") && workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") &&
workspacePage.includes("workspaceRoute(workspaceId, '/settings/runtimes')") && workspacePage.includes(
"workspaceRoute(workspaceId, '/settings/runtimes')",
) &&
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"), workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
"top workspace page should link to Tickets, Runtime Inventory under Settings, and the Workers page", "top workspace page should link to Tickets, Runtime Inventory under Settings, and the Workers page",
); );
@ -99,7 +111,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") && workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
workersPage.includes('<table class="workers-table">') && workersPage.includes('<table class="workers-table">') &&
workersPage.includes('class="icon-action"') && workersPage.includes('class="icon-action"') &&
workersPage.includes('Delete ${worker.label}'), workersPage.includes("Delete ${worker.label}"),
"dedicated Workers page should expose a table, console link target, and icon actions per Worker", "dedicated Workers page should expose a table, console link target, and icon actions per Worker",
); );
assert( assert(
@ -122,10 +134,16 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async
new URL("../sidebar/TicketsNavSection.svelte", import.meta.url), new URL("../sidebar/TicketsNavSection.svelte", import.meta.url),
); );
const ticketsLoad = await Deno.readTextFile( const ticketsLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/tickets/+page.ts", import.meta.url), new URL(
"./../../../routes/w/[workspaceId]/tickets/+page.ts",
import.meta.url,
),
); );
const ticketsPage = await Deno.readTextFile( const ticketsPage = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/tickets/+page.svelte", import.meta.url), new URL(
"./../../../routes/w/[workspaceId]/tickets/+page.svelte",
import.meta.url,
),
); );
const ticketDetailLoad = await Deno.readTextFile( const ticketDetailLoad = await Deno.readTextFile(
new URL( new URL(
@ -146,14 +164,18 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async
"Tickets sidebar section should link to the workspace Tickets surface", "Tickets sidebar section should link to the workspace Tickets surface",
); );
assert( assert(
ticketsLoad.includes('`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`') && ticketsLoad.includes(
'`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`',
) &&
ticketsPage.includes("Notion-style filtering and sorting") && ticketsPage.includes("Notion-style filtering and sorting") &&
ticketsPage.includes("toggleSort('updated_at')") && ticketsPage.includes("toggleSort('updated_at')") &&
ticketsPage.includes("bind:value={visibilityFilter}") && ticketsPage.includes("bind:value={visibilityFilter}") &&
ticketsPage.includes("sortKey = $state<SortKey>('panel')") && ticketsPage.includes("sortKey = $state<SortKey>('panel')") &&
ticketsPage.includes("workspace_action_priority") && ticketsPage.includes("workspace_action_priority") &&
ticketsPage.includes("bind:value={stateFilter}") && ticketsPage.includes("bind:value={stateFilter}") &&
ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"), ticketsPage.includes(
"workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)",
),
"Tickets list should read the workspace-scoped Ticket API and expose sortable/filterable table links", "Tickets list should read the workspace-scoped Ticket API and expose sortable/filterable table links",
); );
assert( assert(
@ -170,10 +192,16 @@ Deno.test("workspace Memory Staging surface uses read-only scoped memory API", a
new URL("../sidebar/MemoryNavSection.svelte", import.meta.url), new URL("../sidebar/MemoryNavSection.svelte", import.meta.url),
); );
const memoryLoad = await Deno.readTextFile( const memoryLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/memory/staging/+page.ts", import.meta.url), new URL(
"./../../../routes/w/[workspaceId]/memory/staging/+page.ts",
import.meta.url,
),
); );
const memoryPage = await Deno.readTextFile( const memoryPage = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/memory/staging/+page.svelte", import.meta.url), new URL(
"./../../../routes/w/[workspaceId]/memory/staging/+page.svelte",
import.meta.url,
),
); );
assert( assert(
@ -182,7 +210,8 @@ Deno.test("workspace Memory Staging surface uses read-only scoped memory API", a
"Memory sidebar section should link to the workspace Memory Staging surface", "Memory sidebar section should link to the workspace Memory Staging surface",
); );
assert( assert(
memoryLoad.includes("workspaceApiPath(params.workspaceId, '/memory/staging')") && memoryLoad.includes("workspaceApiPath(params.workspaceId") &&
memoryLoad.includes('"/memory/staging"') &&
memoryPage.includes("Memory Staging") && memoryPage.includes("Memory Staging") &&
memoryPage.includes("Workspace Server memory authority") && memoryPage.includes("Workspace Server memory authority") &&
memoryPage.includes("data.staging.data.invalid_count") && memoryPage.includes("data.staging.data.invalid_count") &&
@ -236,7 +265,9 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
assert( assert(
consoleLine.includes("function shouldRenderMarkdown") && consoleLine.includes("function shouldRenderMarkdown") &&
consoleLine.includes("item.kind === 'tool'") && consoleLine.includes("item.kind === 'tool'") &&
consoleLine.includes('<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>') && consoleLine.includes(
'<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>',
) &&
consoleLine.includes("{:else if shouldRenderMarkdown(item)}") && consoleLine.includes("{:else if shouldRenderMarkdown(item)}") &&
consoleLine.includes("<RichMarkdown text={item.body || '—'} />"), consoleLine.includes("<RichMarkdown text={item.body || '—'} />"),
"Console should keep markdown rendering to user/assistant/system message bodies and render tool text literally", "Console should keep markdown rendering to user/assistant/system message bodies and render tool text literally",
@ -249,7 +280,9 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
); );
assert( assert(
consoleLine.includes('<div class="console-diff" role="group" aria-label="Edit diff">') && consoleLine.includes(
'<div class="console-diff" role="group" aria-label="Edit diff">',
) &&
consoleLine.includes("{#each item.diff as diffLine}") && consoleLine.includes("{#each item.diff as diffLine}") &&
consoleLine.includes("class={`diff-line ${diffLine.kind}`}") && consoleLine.includes("class={`diff-line ${diffLine.kind}`}") &&
!consoleLine.includes('<pre class="console-diff"'), !consoleLine.includes('<pre class="console-diff"'),
@ -276,7 +309,7 @@ Deno.test("Worker Console exposes a foldable timeline beside the scroll body", a
consolePage.includes("jumpToTimelineMark") && consolePage.includes("jumpToTimelineMark") &&
consoleLine.includes("data-console-line-id={item.id}") && consoleLine.includes("data-console-line-id={item.id}") &&
consolePage.includes("class:timeline-open={timelineOpen}") && consolePage.includes("class:timeline-open={timelineOpen}") &&
consolePage.includes("class=\"timeline-fold\"") && consolePage.includes('class="timeline-fold"') &&
consolePage.includes("expanded={timelineOpen}") && consolePage.includes("expanded={timelineOpen}") &&
!consolePage.includes("{#if timelineOpen}") && !consolePage.includes("{#if timelineOpen}") &&
consolePage.includes("handleTimelineRailPointerDown") && consolePage.includes("handleTimelineRailPointerDown") &&
@ -301,17 +334,19 @@ Deno.test("Worker Console composer fits to content without manual resize", async
); );
assert( assert(
consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") && consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") &&
consolePage.includes("<div class=\"composer-input-shell\">") && consolePage.includes('<div class="composer-input-shell">') &&
!consolePage.includes("handleComposerShellClick") && !consolePage.includes("handleComposerShellClick") &&
consolePage.includes("bind:this={composerTextareaElement}") && consolePage.includes("bind:this={composerTextareaElement}") &&
consolePage.includes('event.key === "PageUp" || event.key === "PageDown"') && consolePage.includes(
'event.key === "PageUp" || event.key === "PageDown"',
) &&
consolePage.includes("scrollConsoleByPage") && consolePage.includes("scrollConsoleByPage") &&
consolePage.includes("class=\"composer-input-footer\"") && consolePage.includes('class="composer-input-footer"') &&
consolePage.includes("pointer-events: none") && consolePage.includes("pointer-events: none") &&
consolePage.includes("class=\"composer-footer-slot\"") && consolePage.includes('class="composer-footer-slot"') &&
consolePage.includes("class=\"composer-send-button\"") && consolePage.includes('class="composer-send-button"') &&
consolePage.includes("pointer-events: auto") && consolePage.includes("pointer-events: auto") &&
consolePage.includes("class=\"composer-send-icon\"") && consolePage.includes('class="composer-send-icon"') &&
consolePage.includes('d="M8 6L12 2L16 6"') && consolePage.includes('d="M8 6L12 2L16 6"') &&
consolePage.includes(".console-composer textarea") && consolePage.includes(".console-composer textarea") &&
consolePage.includes("resize: none") && consolePage.includes("resize: none") &&
@ -366,14 +401,16 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
assert( assert(
!sidebar.includes("RuntimesNavSection") && !sidebar.includes("RuntimesNavSection") &&
settingsModel.includes('id: "runtime-inventory"') && settingsModel.includes('id: "runtime-inventory"') &&
settingsModel.includes('return `${SETTINGS_ROUTE}/runtimes`;'), settingsModel.includes("return `${SETTINGS_ROUTE}/runtimes`;"),
"Runtime inventory should be admin Settings navigation, not primary workspace sidebar navigation", "Runtime inventory should be admin Settings navigation, not primary workspace sidebar navigation",
); );
assert( assert(
runtimesPage.includes("Runtime Inventory") && runtimesPage.includes("Runtime Inventory") &&
runtimesPage.includes("Open workdirs") && runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("runtimes-table") && runtimesPage.includes("runtimes-table") &&
runtimesPage.includes("/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs"), runtimesPage.includes(
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs",
),
"Settings Runtime Inventory page should table Runtimes and link to each Runtime's workdirs", "Settings Runtime Inventory page should table Runtimes and link to each Runtime's workdirs",
); );
assert( assert(
@ -431,7 +468,9 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
assert( assert(
consolePage.includes("workspaceApiPath(workspaceId, path)") && consolePage.includes("workspaceApiPath(workspaceId, path)") &&
consolePage.includes("workerApiPath(") && consolePage.includes("workerApiPath(") &&
consolePage.includes("`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(") && consolePage.includes(
"`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(",
) &&
consolePage.includes("target.workerId"), consolePage.includes("target.workerId"),
"Worker detail should use the scoped backend Worker detail API", "Worker detail should use the scoped backend Worker detail API",
); );
@ -460,14 +499,16 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
"target-change effect should load data without depending on manual refresh state reads", "target-change effect should load data without depending on manual refresh state reads",
); );
assert( assert(
consolePage.includes('const workerRunning = $derived(workerState === "running");') && consolePage.includes(
'const workerRunning = $derived(workerState === "running");',
) &&
consolePage.includes( consolePage.includes(
'const composerEditable = $derived(protocolState === "open" && !sending);', 'const composerEditable = $derived(protocolState === "open" && !sending);',
) && ) &&
consolePage.includes('sendControl({ method: "cancel" }, "Stop")') && consolePage.includes('sendControl({ method: "cancel" }, "Stop")') &&
consolePage.includes("enabled: canSubmitDraft") && consolePage.includes("enabled: canSubmitDraft") &&
consolePage.includes("disabled={!composerEditable}") && consolePage.includes("disabled={!composerEditable}") &&
consolePage.includes('class:stop={workerRunning}') && consolePage.includes("class:stop={workerRunning}") &&
consolePage.includes('"Stop Worker"') && consolePage.includes('"Stop Worker"') &&
consolePage.includes("disabled={composerSubmitDisabled}") && consolePage.includes("disabled={composerSubmitDisabled}") &&
!consolePage.includes("disabled={!inputReady || sending}") && !consolePage.includes("disabled={!inputReady || sending}") &&
@ -502,7 +543,10 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
new URL("../sidebar/sidebar.css", import.meta.url), new URL("../sidebar/sidebar.css", import.meta.url),
); );
const workspaceLayout = await Deno.readTextFile( const workspaceLayout = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url), new URL(
"./../../../routes/w/[workspaceId]/+layout.svelte",
import.meta.url,
),
); );
const workspaceLayoutLoad = await Deno.readTextFile( const workspaceLayoutLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.ts", import.meta.url), new URL("./../../../routes/w/[workspaceId]/+layout.ts", import.meta.url),
@ -562,7 +606,9 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
assert( assert(
workspaceLayout.includes("{#snippet workspaceSidebar()}") && workspaceLayout.includes("{#snippet workspaceSidebar()}") &&
workspaceLayout.includes("WorkspaceSidebar") && workspaceLayout.includes("WorkspaceSidebar") &&
workspaceLayout.includes("<SidebarOverride sidebar={workspaceSidebar} />") && workspaceLayout.includes(
"<SidebarOverride sidebar={workspaceSidebar} />",
) &&
workspaceLayoutLoad.includes("params.workspaceId") && workspaceLayoutLoad.includes("params.workspaceId") &&
workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"), workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"),
"Workspace layout should load workspace data and register a WorkspaceSidebar snippet", "Workspace layout should load workspace data and register a WorkspaceSidebar snippet",
@ -592,7 +638,8 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"SidebarOverride should register and clean up the child-provided sidebar snippet", "SidebarOverride should register and clean up the child-provided sidebar snippet",
); );
assert( assert(
rootLayoutLoad.includes('"/account"') && rootLayoutLoad.includes('"/login/device"'), rootLayoutLoad.includes('"/account"') &&
rootLayoutLoad.includes('"/login/device"'),
"Root layout should not redirect account and device-login public routes to a workspace", "Root layout should not redirect account and device-login public routes to a workspace",
); );
}); });

View File

@ -6,7 +6,10 @@ import {
SETTINGS_SECTIONS, SETTINGS_SECTIONS,
settingsSectionHref, settingsSectionHref,
} from "./model.ts"; } from "./model.ts";
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from "./profile-routes.ts"; import {
profileSourceTreeSettingsHref,
virtualProfilePathForCreate,
} from "./profile-routes.ts";
declare const Deno: { declare const Deno: {
test(name: string, fn: () => void): void; test(name: string, fn: () => void): void;
@ -104,7 +107,6 @@ Deno.test("diagnostic labels preserve severity and code", () => {
); );
}); });
Deno.test("profile source tree routes use encoded scoped ids", () => { Deno.test("profile source tree routes use encoded scoped ids", () => {
const href = profileSourceTreeSettingsHref("workspace a", "project/tree"); const href = profileSourceTreeSettingsHref("workspace a", "project/tree");
assert( assert(
@ -115,7 +117,18 @@ Deno.test("profile source tree routes use encoded scoped ids", () => {
}); });
Deno.test("profile source create paths are normalized to virtual profile paths", () => { Deno.test("profile source create paths are normalized to virtual profile paths", () => {
assert(virtualProfilePathForCreate("alpha.dcdl") === "profiles/alpha.dcdl", "bare file names are scoped"); assert(
assert(virtualProfilePathForCreate("profiles/alpha.dcdl") === "profiles/alpha.dcdl", "virtual paths are preserved"); virtualProfilePathForCreate("alpha.dcdl") === "profiles/alpha.dcdl",
assert(virtualProfilePathForCreate("project:profiles/alpha.dcdl") === "project:profiles/alpha.dcdl", "safe virtual namespaces are preserved"); "bare file names are scoped",
);
assert(
virtualProfilePathForCreate("profiles/alpha.dcdl") ===
"profiles/alpha.dcdl",
"virtual paths are preserved",
);
assert(
virtualProfilePathForCreate("project:profiles/alpha.dcdl") ===
"project:profiles/alpha.dcdl",
"safe virtual namespaces are preserved",
);
}); });

View File

@ -1,7 +1,4 @@
import { import { workspaceApiJson, workspaceApiJsonWithBody } from "../api/http";
workspaceApiJson,
workspaceApiJsonWithBody,
} from "../api/http";
import type { import type {
ProfileSettingsMutationResponse, ProfileSettingsMutationResponse,
ProfileSettingsResponse, ProfileSettingsResponse,
@ -124,7 +121,9 @@ export function fetchProfileSourceTree(
sourceTreeId: string, sourceTreeId: string,
): Promise<WorkspaceProfileSourceTreeResponse> { ): Promise<WorkspaceProfileSourceTreeResponse> {
return workspaceApiJson( return workspaceApiJson(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}`, `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
encodeURIComponent(sourceTreeId)
}`,
); );
} }
@ -134,7 +133,9 @@ export function fetchProfileTreeFile(
path: string, path: string,
): Promise<WorkspaceProfileSourceTreeFileResponse> { ): Promise<WorkspaceProfileSourceTreeFileResponse> {
return workspaceApiJson( return workspaceApiJson(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file?path=${encodeURIComponent(path)}`, `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
encodeURIComponent(sourceTreeId)
}/file?path=${encodeURIComponent(path)}`,
); );
} }
@ -144,7 +145,9 @@ export function writeProfileTreeFile(
request: { path: string; content: string; revision?: string | null }, request: { path: string; content: string; revision?: string | null },
): Promise<WorkspaceProfileSourceTreeFileResponse> { ): Promise<WorkspaceProfileSourceTreeFileResponse> {
return workspaceApiJsonWithBody( return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`, `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
encodeURIComponent(sourceTreeId)
}/file`,
{ method: "PUT", body: JSON.stringify(request) }, { method: "PUT", body: JSON.stringify(request) },
); );
} }
@ -155,7 +158,9 @@ export function deleteProfileTreeFile(
request: { path: string; revision: string }, request: { path: string; revision: string },
): Promise<WorkspaceProfileSourceTreeResponse> { ): Promise<WorkspaceProfileSourceTreeResponse> {
return workspaceApiJsonWithBody( return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`, `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${
encodeURIComponent(sourceTreeId)
}/file`,
{ method: "DELETE", body: JSON.stringify(request) }, { method: "DELETE", body: JSON.stringify(request) },
); );
} }

View File

@ -2,14 +2,21 @@ export function profileSettingsHref(workspaceId: string): string {
return `/w/${encodeURIComponent(workspaceId)}/settings/profiles`; return `/w/${encodeURIComponent(workspaceId)}/settings/profiles`;
} }
export function profileSourceTreeSettingsHref(workspaceId: string, sourceTreeId: string): string { export function profileSourceTreeSettingsHref(
return `${profileSettingsHref(workspaceId)}/trees/${encodeURIComponent(sourceTreeId)}`; workspaceId: string,
sourceTreeId: string,
): string {
return `${profileSettingsHref(workspaceId)}/trees/${
encodeURIComponent(sourceTreeId)
}`;
} }
export function virtualProfilePathForCreate(input: string): string { export function virtualProfilePathForCreate(input: string): string {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) return ""; if (!trimmed) return "";
if (trimmed.startsWith("project:") || trimmed.startsWith("workspace:")) return trimmed; if (trimmed.startsWith("project:") || trimmed.startsWith("workspace:")) {
return trimmed;
}
if (trimmed.startsWith("profiles/")) return trimmed; if (trimmed.startsWith("profiles/")) return trimmed;
return `profiles/${trimmed}`; return `profiles/${trimmed}`;
} }

View File

@ -25,18 +25,22 @@ function repositories(
} }
Deno.test("repository nav does not invent main for an empty registry", () => { Deno.test("repository nav does not invent main for an empty registry", () => {
const projection = projectRepositoryNav({ const projection = projectRepositoryNav(
workspace_id: "workspace-1", {
items: [], workspace_id: "workspace-1",
source: "workspace_backend_config", items: [],
diagnostics: [ source: "workspace_backend_config",
{ diagnostics: [
code: "repository_config_empty", {
severity: "warning", code: "repository_config_empty",
message: "No repositories configured", severity: "warning",
}, message: "No repositories configured",
], },
}, "/w/workspace-1", "workspace-1"); ],
},
"/w/workspace-1",
"workspace-1",
);
assertEquals(projection.count, 0); assertEquals(projection.count, 0);
assertEquals(projection.items, []); assertEquals(projection.items, []);

View File

@ -25,7 +25,10 @@ export function projectRepositoryNav(
count: summaries.length, count: summaries.length,
diagnostics: repositories?.diagnostics ?? [], diagnostics: repositories?.diagnostics ?? [],
items: summaries.map((repository) => { items: summaries.map((repository) => {
const href = workspaceRoute(workspaceId, `/repositories/${encodeURIComponent(repository.id)}`); const href = workspaceRoute(
workspaceId,
`/repositories/${encodeURIComponent(repository.id)}`,
);
return { return {
id: repository.id, id: repository.id,
title: repository.display_name || repository.id, title: repository.display_name || repository.id,

View File

@ -1,249 +1,249 @@
@layer reset, tokens, base, layout, components; @layer reset, tokens, base, layout, components;
@layer components { @layer components {
.sidebar-frame { .sidebar-frame {
grid-column: 1; grid-column: 1;
grid-row: 1 / 3; grid-row: 1 / 3;
width: clamp(220px, 20vw, 280px); width: clamp(220px, 20vw, 280px);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: var(--space-4) var(--space-3); padding: var(--space-4) var(--space-3);
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
} }
.sidebar-frame.folded { .sidebar-frame.folded {
width: max-content; width: max-content;
overflow: hidden; overflow: hidden;
padding-inline: var(--space-2); padding-inline: var(--space-2);
} }
.sidebar-frame-content, .sidebar-frame-content,
.global-sidebar, .global-sidebar,
.workspace-sidebar { .workspace-sidebar {
min-width: 0; min-width: 0;
} }
.global-sidebar, .global-sidebar,
.global-sidebar-section, .global-sidebar-section,
.workspace-sidebar { .workspace-sidebar {
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-2);
} }
.sidebar-header { .sidebar-header {
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-2);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
min-width: 0; min-width: 0;
} }
.sidebar-control-row, .sidebar-control-row,
.sidebar-actions-row { .sidebar-actions-row {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-1); gap: var(--space-1);
} }
.sidebar-control-row { .sidebar-control-row {
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
.sidebar-frame.folded .sidebar-control-row { .sidebar-frame.folded .sidebar-control-row {
justify-content: center; justify-content: center;
margin-bottom: 0; margin-bottom: 0;
} }
.sidebar-title-row { .sidebar-title-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
min-width: 0; min-width: 0;
} }
.workspace-label { .workspace-label {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
.workspace-name { .workspace-name {
display: block; display: block;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
border-radius: var(--radius-soft); border-radius: var(--radius-soft);
padding: 0.45rem 0.6rem; padding: 0.45rem 0.6rem;
color: var(--text-strong); color: var(--text-strong);
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 800; font-weight: 800;
line-height: 1.25; line-height: 1.25;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.workspace-status { .workspace-status {
margin: var(--space-1) 0 0; margin: var(--space-1) 0 0;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.78rem; font-size: 0.78rem;
line-height: 1.35; line-height: 1.35;
} }
.workspace-status.error, .workspace-status.error,
.section-state.error, .section-state.error,
.error { .error {
color: var(--danger); color: var(--danger);
} }
.sidebar-icon-button, .sidebar-icon-button,
.sidebar-fold-button { .sidebar-fold-button {
flex: 0 0 auto; flex: 0 0 auto;
display: grid; display: grid;
place-items: center; place-items: center;
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: var(--radius-soft); border-radius: var(--radius-soft);
color: var(--text-muted); color: var(--text-muted);
text-decoration: none; text-decoration: none;
} }
.sidebar-fold-button { .sidebar-fold-button {
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--bg-raised); background: var(--bg-raised);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
color: var(--text-strong); color: var(--text-strong);
cursor: pointer; cursor: pointer;
} }
.sidebar-icon { .sidebar-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
fill: none; fill: none;
stroke: currentColor; stroke: currentColor;
stroke-width: 2; stroke-width: 2;
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
} }
.sidebar-icon-button:hover, .sidebar-icon-button:hover,
.sidebar-icon-button:focus-visible, .sidebar-icon-button:focus-visible,
.sidebar-fold-button:hover, .sidebar-fold-button:hover,
.sidebar-fold-button:focus-visible { .sidebar-fold-button:focus-visible {
background: var(--interactive-hover); background: var(--interactive-hover);
color: var(--text-muted); color: var(--text-muted);
} }
.sidebar-sections { .sidebar-sections {
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
min-width: 0; min-width: 0;
} }
.nav-section { .nav-section {
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-2);
} }
.section-heading-row, .section-heading-row,
.section-header { .section-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-3); gap: var(--space-3);
} }
.section-heading-row h2, .section-heading-row h2,
.section-header, .section-header,
.sidebar-section-label { .sidebar-section-label {
color: var(--text-faint); color: var(--text-faint);
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 750; font-weight: 750;
letter-spacing: 0.11em; letter-spacing: 0.11em;
text-transform: uppercase; text-transform: uppercase;
} }
.section-heading-row h2, .section-heading-row h2,
.sidebar-section-label { .sidebar-section-label {
margin: 0; margin: 0;
} }
.section-heading-link { .section-heading-link {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
.section-heading-link:hover, .section-heading-link:hover,
.section-heading-link:focus-visible, .section-heading-link:focus-visible,
.section-heading-link.active { .section-heading-link.active {
color: var(--accent); color: var(--accent);
} }
.section-count { .section-count {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.72rem; font-size: 0.72rem;
line-height: 1; line-height: 1;
} }
.nav-list, .nav-list,
.sidebar-list { .sidebar-list {
display: grid; display: grid;
gap: var(--space-1); gap: var(--space-1);
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
} }
.nav-item, .nav-item,
.objective-link, .objective-link,
.sidebar-link { .sidebar-link {
display: grid; display: grid;
gap: 3px; gap: 3px;
min-width: 0; min-width: 0;
margin-inline: calc(-1 * var(--space-2)); margin-inline: calc(-1 * var(--space-2));
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
border-radius: var(--radius-soft); border-radius: var(--radius-soft);
color: inherit; color: inherit;
text-align: left; text-align: left;
text-decoration: none; text-decoration: none;
transition: background-color 140ms ease, color 140ms ease; transition: background-color 140ms ease, color 140ms ease;
} }
a.nav-item:hover, a.nav-item:hover,
a.nav-item:focus-visible, a.nav-item:focus-visible,
a.objective-link:hover, a.objective-link:hover,
a.objective-link:focus-visible, a.objective-link:focus-visible,
a.sidebar-link:hover, a.sidebar-link:hover,
a.sidebar-link:focus-visible { a.sidebar-link:focus-visible {
background: var(--interactive-hover); background: var(--interactive-hover);
} }
a.nav-item.active, a.nav-item.active,
a.objective-link.active, a.objective-link.active,
a.sidebar-link.active { a.sidebar-link.active {
background: var(--interactive-selected); background: var(--interactive-selected);
} }
a.nav-item.active .item-title, a.nav-item.active .item-title,
a.objective-link.active .item-title, a.objective-link.active .item-title,
a.sidebar-link.active { a.sidebar-link.active {
color: var(--accent); color: var(--accent);
} }
.item-title, .item-title,
.item-meta { .item-meta {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.item-title { .item-title {
color: var(--text-strong); color: var(--text-strong);
font-weight: 650; font-weight: 650;
} }
.item-meta { .item-meta {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.82rem; font-size: 0.82rem;
} }
.worker-nav-item { .worker-nav-item {
gap: 2px; gap: 2px;
} }
.worker-nav-item.disabled { .worker-nav-item.disabled {
cursor: default; cursor: default;
opacity: 0.62; opacity: 0.62;
} }
.worker-nav-item.disabled .item-title { .worker-nav-item.disabled .item-title {
color: var(--text-muted); color: var(--text-muted);
} }
.worker-title-row { .worker-title-row {
display: grid; display: grid;
grid-template-columns: minmax(0, max-content) minmax(0, 1fr); grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
align-items: baseline; align-items: baseline;
gap: var(--space-2); gap: var(--space-2);
min-width: 0; min-width: 0;
} }
.worker-task-title { .worker-task-title {
overflow: hidden; overflow: hidden;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.82rem; font-size: 0.82rem;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.sidebar-frame, .sidebar-frame,
.sidebar-frame.folded { .sidebar-frame.folded {
grid-column: 1; grid-column: 1;
grid-row: 1; grid-row: 1;
width: auto; width: auto;
border-right: 0; border-right: 0;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
} }
} }

View File

@ -319,12 +319,12 @@ export type RepositoryLogResponse = {
}; };
export type MemoryCandidateKind = export type MemoryCandidateKind =
| 'preference' | "preference"
| 'working_assumption' | "working_assumption"
| 'constraint' | "constraint"
| 'decision' | "decision"
| 'open_question' | "open_question"
| 'lesson'; | "lesson";
export type MemorySourceRef = { export type MemorySourceRef = {
segment_id: string; segment_id: string;
@ -387,7 +387,11 @@ export type TicketSummary = {
updated_at?: string | null; updated_at?: string | null;
queued_by?: string | null; queued_by?: string | null;
queued_at?: string | null; queued_at?: string | null;
workspace_action_priority?: 'ready_for_queue' | 'active_work' | 'background' | null; workspace_action_priority?:
| "ready_for_queue"
| "active_work"
| "background"
| null;
record_source?: string; record_source?: string;
}; };

View File

@ -35,17 +35,23 @@ export function defaultWorkerLaunchForm(
const preferredProfile = const preferredProfile =
options?.profiles.find((candidate) => candidate.id === "builtin:coder") ?? options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
options?.profiles[0]; options?.profiles[0];
const availableWorkingDirectories = options?.working_directories.filter((directory) => const availableWorkingDirectories =
directory.status === "active" && options?.working_directories.filter((directory) =>
directory.cleanliness === "clean" && directory.status === "active" &&
directory.primary_worker_id == null && directory.cleanliness === "clean" &&
directory.occupied_by == null directory.primary_worker_id == null &&
) ?? []; directory.occupied_by == null
) ?? [];
const selectedRuntime = current.runtime_id const selectedRuntime = current.runtime_id
? options?.runtimes.find((runtime) => runtime.runtime_id === current.runtime_id) ? options?.runtimes.find((runtime) =>
runtime.runtime_id === current.runtime_id
)
: preferredRuntime; : preferredRuntime;
const workdirlessRuntime = selectedRuntime?.working_directory_required === false; const workdirlessRuntime =
const preferredWorkingDirectory = workdirlessRuntime ? undefined : availableWorkingDirectories[0]; selectedRuntime?.working_directory_required === false;
const preferredWorkingDirectory = workdirlessRuntime
? undefined
: availableWorkingDirectories[0];
const preferredRepository = const preferredRepository =
options?.repositories.find((repository) => options?.repositories.find((repository) =>
repository.id === current.working_directory_repository_id repository.id === current.working_directory_repository_id
@ -60,12 +66,13 @@ export function defaultWorkerLaunchForm(
? current.profile ? current.profile
: preferredProfile?.id || "", : preferredProfile?.id || "",
initial_text: current.initial_text, initial_text: current.initial_text,
working_directory_id: !workdirlessRuntime && availableWorkingDirectories.some( working_directory_id:
(directory) => !workdirlessRuntime && availableWorkingDirectories.some(
directory.working_directory_id === current.working_directory_id, (directory) =>
) directory.working_directory_id === current.working_directory_id,
? current.working_directory_id )
: preferredWorkingDirectory?.working_directory_id || "", ? current.working_directory_id
: preferredWorkingDirectory?.working_directory_id || "",
working_directory_repository_id: current.working_directory_repository_id || working_directory_repository_id: current.working_directory_repository_id ||
preferredRepository?.id || "", preferredRepository?.id || "",
working_directory_selector: current.working_directory_selector || working_directory_selector: current.working_directory_selector ||

View File

@ -1,5 +1,8 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types"; import type {
RepositoryListResponse,
WorkspaceResponse,
} from "$lib/workspace/sidebar/types";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async ({ fetch, params }) => { export const load: LayoutLoad = async ({ fetch, params }) => {

View File

@ -1,13 +1,13 @@
import { loadJson, workspaceApiPath } from '$lib/workspace/api/http'; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { MemoryStagingListResponse } from '$lib/workspace/sidebar/types'; import type { MemoryStagingListResponse } from "$lib/workspace/sidebar/types";
import type { PageLoad } from './$types'; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
staging: await loadJson<MemoryStagingListResponse>( staging: await loadJson<MemoryStagingListResponse>(
fetch, fetch,
`${workspaceApiPath(params.workspaceId, '/memory/staging')}?limit=200`, `${workspaceApiPath(params.workspaceId, "/memory/staging")}?limit=200`,
), ),
}; };
}; };

View File

@ -9,7 +9,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const objectiveId = params.objectiveId; const objectiveId = params.objectiveId;
const [objectives, objective] = await Promise.all([ const [objectives, objective] = await Promise.all([
loadJson<ObjectiveListResponse>(fetch, apiPath('/objectives')), loadJson<ObjectiveListResponse>(fetch, apiPath("/objectives")),
loadJson<ObjectiveDetail>( loadJson<ObjectiveDetail>(
fetch, fetch,
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`), apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),

View File

@ -1,5 +1,7 @@
export function load( export function load(
{ params }: { params: { workspaceId: string; runtimeId: string; workerId: string } }, { params }: {
params: { workspaceId: string; runtimeId: string; workerId: string };
},
) { ) {
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,

View File

@ -1,4 +1,4 @@
import type { PageLoad } from './$types'; import type { PageLoad } from "./$types";
export const load: PageLoad = ({ params }) => ({ export const load: PageLoad = ({ params }) => ({
sourceTreeId: params.sourceTreeId, sourceTreeId: params.sourceTreeId,

View File

@ -10,7 +10,10 @@ import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
const runtimeId = params.runtimeId; const runtimeId = params.runtimeId;
const [runtimes, workdirs, cleanupPlan] = await Promise.all([ const [runtimes, workdirs, cleanupPlan] = await Promise.all([
loadJson<ListResponse<Runtime>>(fetch, workspaceApiPath(params.workspaceId, "/runtimes")), loadJson<ListResponse<Runtime>>(
fetch,
workspaceApiPath(params.workspaceId, "/runtimes"),
),
loadJson<BrowserWorkingDirectoryListResponse>( loadJson<BrowserWorkingDirectoryListResponse>(
fetch, fetch,
workspaceApiPath( workspaceApiPath(
@ -20,7 +23,10 @@ export const load: PageLoad = async ({ fetch, params }) => {
), ),
loadJson<RuntimeCleanupPlanResponse>( loadJson<RuntimeCleanupPlanResponse>(
fetch, fetch,
workspaceApiPath(params.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`), workspaceApiPath(
params.workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`,
),
), ),
]); ]);

View File

@ -6,7 +6,10 @@ export const load = (async ({ fetch, params }) => {
const ticketId = params.ticketId; const ticketId = params.ticketId;
const ticket = await loadJson<TicketDetail>( const ticket = await loadJson<TicketDetail>(
fetch, fetch,
workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(ticketId)}`), workspaceApiPath(
params.workspaceId,
`/tickets/${encodeURIComponent(ticketId)}`,
),
); );
return { return {

View File

@ -1,5 +1,9 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { ListResponse, RuntimeCleanupPlanResponse, Worker } from "$lib/workspace/sidebar/types"; import type {
ListResponse,
RuntimeCleanupPlanResponse,
Worker,
} from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
@ -7,12 +11,17 @@ export const load: PageLoad = async ({ fetch, params }) => {
fetch, fetch,
workspaceApiPath(params.workspaceId, "/workers"), workspaceApiPath(params.workspaceId, "/workers"),
); );
const runtimeIds = Array.from(new Set(workers.data?.items.map((worker) => worker.runtime_id) ?? [])); const runtimeIds = Array.from(
new Set(workers.data?.items.map((worker) => worker.runtime_id) ?? []),
);
const cleanupPlanEntries = await Promise.all( const cleanupPlanEntries = await Promise.all(
runtimeIds.map(async (runtimeId) => { runtimeIds.map(async (runtimeId) => {
const cleanupPlan = await loadJson<RuntimeCleanupPlanResponse>( const cleanupPlan = await loadJson<RuntimeCleanupPlanResponse>(
fetch, fetch,
workspaceApiPath(params.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`), workspaceApiPath(
params.workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`,
),
); );
return [runtimeId, cleanupPlan] as const; return [runtimeId, cleanupPlan] as const;
}), }),

View File

@ -1,18 +1,18 @@
import adapter from '@sveltejs/adapter-static'; import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { kit: {
adapter: adapter({ adapter: adapter({
pages: 'build', pages: "build",
assets: 'build', assets: "build",
fallback: 'index.html', fallback: "index.html",
precompress: false, precompress: false,
strict: true strict: true,
}) }),
} },
}; };
export default config; export default config;

View File

@ -1,20 +1,20 @@
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), sveltekit()],
server: { server: {
allowedHosts: ['develop.hareworks.net'], allowedHosts: ["develop.hareworks.net"],
watch: { ignored: ['**/.tmp*', '**/.tmp*/**'] }, watch: { ignored: ["**/.tmp*", "**/.tmp*/**"] },
proxy: { proxy: {
'/api': { "/api": {
target: 'http://127.0.0.1:8787', target: "http://127.0.0.1:8787",
changeOrigin: true, changeOrigin: true,
ws: true ws: true,
} },
} },
} },
}); });