Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
@@ -22,6 +22,16 @@ export type Permission = "read" | "write";
|
||||
|
||||
export type InFlightToolCallState = "pending" | "streaming_args" | "done";
|
||||
|
||||
export type CommandStatus = "running" | "completed" | "failed" | "timed_out" | "cancelled";
|
||||
|
||||
export type CommandStream = "stdout" | "stderr";
|
||||
|
||||
export type CommandStreamSlice = { start_offset: number, end_offset: number, content: string, truncated: boolean, };
|
||||
|
||||
export type CommandSnapshot = { command_id: string, tool_call_id: string | null, status: CommandStatus, started_at_ms: number, observed_at_ms: number, last_output_at_ms: number | null, stdout: CommandStreamSlice, stderr: CommandStreamSlice, exit_code: number | null, };
|
||||
|
||||
export type CommandEvent = { "kind": "started", command_id: string, tool_call_id: string | null, observed_at_ms: number, } | { "kind": "output", command_id: string, stream: CommandStream, start_offset: number, end_offset: number, content: string, observed_at_ms: number, } | { "kind": "terminal", command_id: string, status: CommandStatus, exit_code: number | null, stdout_end_offset: number, stderr_end_offset: number, observed_at_ms: number, };
|
||||
|
||||
export type ScopeRule = {
|
||||
/**
|
||||
* Target path. Must be absolute by the time a `Scope` is built from
|
||||
@@ -51,7 +61,7 @@ export type RewindSummary = { truncated_to_entries: number, discarded_entries: n
|
||||
|
||||
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>, commands?: Array<CommandSnapshot>, };
|
||||
|
||||
export type InternalWorkerKind = "sub_worker";
|
||||
|
||||
@@ -178,4 +188,4 @@ in_flight?: InFlightSnapshot,
|
||||
* Parent-owned Internal Worker sessions visible to this client.
|
||||
* Service-private Internal Workers are deliberately excluded.
|
||||
*/
|
||||
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "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" };
|
||||
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "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" };
|
||||
|
||||
@@ -38,25 +38,31 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("root layout bootstraps only the scoped workspace entry", async () => {
|
||||
Deno.test("root layout leaves Workspace selection explicit", async () => {
|
||||
const layout = await Deno.readTextFile(
|
||||
new URL("./../../../routes/+layout.ts", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
|
||||
"unscoped layout may use only the workspace-id bootstrap endpoint",
|
||||
!layout.includes("/api/workspace") &&
|
||||
!layout.includes("redirect(") &&
|
||||
layout.includes("Workspace selection is explicit"),
|
||||
"root layout must not infer or redirect to a singleton Workspace",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Workspace route changes dispose old multiplexed subscription state", async () => {
|
||||
const [layout, multiplexer] = await Promise.all([
|
||||
Deno.readTextFile(
|
||||
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
|
||||
),
|
||||
Deno.readTextFile(new URL("./../multiplexer.ts", import.meta.url)),
|
||||
]);
|
||||
assert(
|
||||
layout.includes("throw redirect(307") &&
|
||||
layout.includes("workspaceRoute(workspace.data.workspace_id)") &&
|
||||
!layout.includes("scopedCompatibilityRoute") &&
|
||||
!layout.includes("workspaceRoute(workspaceId, pathname)"),
|
||||
"root layout should redirect only to the scoped workspace entry",
|
||||
);
|
||||
assert(
|
||||
!layout.includes("`/api${path}`") &&
|
||||
!layout.includes('"/api/repositories"'),
|
||||
"layout must not fall back to unscoped workspace-scoped API calls",
|
||||
layout.includes("disposeWorkspaceMultiplexer(workspaceId)") &&
|
||||
multiplexer.includes("multiplexers.delete(workspaceId)") &&
|
||||
multiplexer.includes("this.#subscriptions.clear()") &&
|
||||
multiplexer.includes("this.#socket?.close()"),
|
||||
"changing Workspace must dispose old subscriptions and transport state",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
export type WorkspaceCatalogRecord = {
|
||||
workspace_id: string;
|
||||
owner_account_id: string | null;
|
||||
display_name: string;
|
||||
state: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceRepositoryRecord = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
uri: string;
|
||||
default_ref: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
|
||||
repositories: WorkspaceRepositoryRecord[];
|
||||
repository_error?: string;
|
||||
};
|
||||
|
||||
export type CreateWorkspaceRequest = {
|
||||
operation_key: string;
|
||||
display_name: string;
|
||||
repository: {
|
||||
uri: string;
|
||||
display_name: string | null;
|
||||
default_ref: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type CreateWorkspaceResponse = {
|
||||
workspace: WorkspaceCatalogRecord;
|
||||
repository: WorkspaceRepositoryRecord;
|
||||
config_revision: number;
|
||||
request_fingerprint: string;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
export class WorkspaceCatalogError extends Error {
|
||||
constructor(
|
||||
public readonly status: number | null,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WorkspaceCatalogError";
|
||||
}
|
||||
}
|
||||
|
||||
type Fetch = typeof globalThis.fetch;
|
||||
|
||||
export async function listWorkspaces(
|
||||
fetcher: Fetch,
|
||||
): Promise<WorkspaceCatalogRecord[]> {
|
||||
return await fetchJson<WorkspaceCatalogRecord[]>(
|
||||
fetcher,
|
||||
"/api/workspaces?limit=200",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listWorkspaceRepositories(
|
||||
fetcher: Fetch,
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceRepositoryRecord[]> {
|
||||
return await fetchJson<WorkspaceRepositoryRecord[]>(
|
||||
fetcher,
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/repositories`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadWorkspaceCatalog(
|
||||
fetcher: Fetch,
|
||||
): Promise<WorkspaceCatalogItem[]> {
|
||||
const workspaces = await listWorkspaces(fetcher);
|
||||
return await Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
try {
|
||||
return {
|
||||
...workspace,
|
||||
repositories: await listWorkspaceRepositories(
|
||||
fetcher,
|
||||
workspace.workspace_id,
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...workspace,
|
||||
repositories: [],
|
||||
repository_error: errorMessage(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createWorkspace(
|
||||
fetcher: Fetch,
|
||||
request: CreateWorkspaceRequest,
|
||||
): Promise<CreateWorkspaceResponse> {
|
||||
return await fetchJson<CreateWorkspaceResponse>(fetcher, "/api/workspaces", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
export function creationErrorMessage(error: unknown): string {
|
||||
if (!(error instanceof WorkspaceCatalogError)) {
|
||||
return `Network error. The same operation can be retried safely. ${
|
||||
errorMessage(error)
|
||||
}`;
|
||||
}
|
||||
switch (error.status) {
|
||||
case 400:
|
||||
return `Validation failed. ${error.message}`;
|
||||
case 401:
|
||||
case 403:
|
||||
return `You are not authorized to create this Workspace. ${error.message}`;
|
||||
case 409:
|
||||
return `Creation conflicts with current Backend state. ${error.message}`;
|
||||
default:
|
||||
return `Workspace creation failed. The same operation can be retried safely. ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOperationKey(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return `web-workspace-create-${crypto.randomUUID()}`;
|
||||
}
|
||||
return `web-workspace-create-${Date.now()}-${
|
||||
Math.random().toString(16).slice(2)
|
||||
}`;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(
|
||||
fetcher: Fetch,
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(input, init);
|
||||
} catch (error) {
|
||||
throw new WorkspaceCatalogError(null, errorMessage(error));
|
||||
}
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status} ${response.statusText}`.trim();
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (typeof body?.message === "string") detail = body.message;
|
||||
else if (typeof body?.error === "string") detail = body.error;
|
||||
} catch {
|
||||
// Preserve the bounded status text when the Backend did not return JSON.
|
||||
}
|
||||
throw new WorkspaceCatalogError(response.status, detail);
|
||||
}
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -337,6 +337,137 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole streams distinct Bash stdout and stderr through terminal status", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "command-tool",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "bash-stream",
|
||||
name: "Bash",
|
||||
arguments: JSON.stringify({ command: "long-command" }),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "command-started",
|
||||
event: {
|
||||
event: "command",
|
||||
data: {
|
||||
event: {
|
||||
kind: "started",
|
||||
command_id: "command-1",
|
||||
tool_call_id: "bash-stream",
|
||||
observed_at_ms: 1000,
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "command-stdout",
|
||||
event: {
|
||||
event: "command",
|
||||
data: {
|
||||
event: {
|
||||
kind: "output",
|
||||
command_id: "command-1",
|
||||
stream: "stdout",
|
||||
start_offset: 0,
|
||||
end_offset: 6,
|
||||
content: "ready\n",
|
||||
observed_at_ms: 1100,
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "command-stderr",
|
||||
event: {
|
||||
event: "command",
|
||||
data: {
|
||||
event: {
|
||||
kind: "output",
|
||||
command_id: "command-1",
|
||||
stream: "stderr",
|
||||
start_offset: 0,
|
||||
end_offset: 5,
|
||||
content: "warn\n",
|
||||
observed_at_ms: 1200,
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "command-terminal",
|
||||
event: {
|
||||
event: "command",
|
||||
data: {
|
||||
event: {
|
||||
kind: "terminal",
|
||||
command_id: "command-1",
|
||||
status: "failed",
|
||||
exit_code: 7,
|
||||
stdout_end_offset: 6,
|
||||
stderr_end_offset: 5,
|
||||
observed_at_ms: 1300,
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
|
||||
assert(line.body.includes("elapsed 300ms"), line.body);
|
||||
assert(line.body.includes("stdout:\nready\n"), line.body);
|
||||
assert(line.body.includes("stderr:\nwarn\n"), line.body);
|
||||
assertEquals(line.streaming, false);
|
||||
assertEquals(line.error, true);
|
||||
});
|
||||
|
||||
Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
||||
const snapshot = snapshotEvent("/repo");
|
||||
if (snapshot.event !== "snapshot") throw new Error("snapshot fixture expected");
|
||||
snapshot.data.status = "running";
|
||||
snapshot.data.in_flight = {
|
||||
blocks: [{
|
||||
kind: "tool_call",
|
||||
id: "bash-snapshot",
|
||||
name: "Bash",
|
||||
args: JSON.stringify({ command: "slow" }),
|
||||
state: "done",
|
||||
}],
|
||||
commands: [{
|
||||
command_id: "command-2",
|
||||
tool_call_id: "bash-snapshot",
|
||||
status: "running",
|
||||
started_at_ms: 1000,
|
||||
observed_at_ms: 1250,
|
||||
last_output_at_ms: 1200,
|
||||
stdout: {
|
||||
start_offset: 1024,
|
||||
end_offset: 1031,
|
||||
content: "tail\n",
|
||||
truncated: true,
|
||||
},
|
||||
stderr: { start_offset: 0, end_offset: 0, content: "", truncated: false },
|
||||
exit_code: null,
|
||||
}],
|
||||
};
|
||||
|
||||
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assert(line.body.includes("Bash — running…"), line.body);
|
||||
assert(
|
||||
line.body.includes("elapsed 250ms · last output at +200ms"),
|
||||
line.body,
|
||||
);
|
||||
assert(line.body.includes("[stdout tail; earlier output omitted]"), line.body);
|
||||
assert(line.body.includes("stdout:\ntail\n"), line.body);
|
||||
assertEquals(line.streaming, true);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole caps default tool request and result previews", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
@@ -1295,7 +1426,10 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => {
|
||||
}]);
|
||||
assertEquals(projection.lines, []);
|
||||
assertEquals(projection.internalWorkers.length, 1);
|
||||
assertEquals(projection.internalWorkers[0].console.lines[0].body, "child output");
|
||||
assertEquals(
|
||||
projection.internalWorkers[0].console.lines[0].body,
|
||||
"child output",
|
||||
);
|
||||
|
||||
projection = projector.append([{
|
||||
eventId: "2",
|
||||
@@ -1372,15 +1506,112 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
|
||||
},
|
||||
}]);
|
||||
const projection = projector.append([{ eventId: "snapshot", event }]);
|
||||
assertEquals(projection.internalWorkers.map((worker) => worker.worker.session_id), [
|
||||
"replacement",
|
||||
]);
|
||||
assertEquals(
|
||||
projection.internalWorkers.map((worker) => worker.worker.session_id),
|
||||
[
|
||||
"replacement",
|
||||
],
|
||||
);
|
||||
const childLines = projection.internalWorkers[0].console.lines;
|
||||
assertEquals(childLines.length, 1);
|
||||
assertEquals(new Set(childLines.map((line) => line.id)).size, 1);
|
||||
assertEquals(childLines[0].kind, "tool");
|
||||
});
|
||||
|
||||
Deno.test("terminal Internal Worker removal drops descendants and fences late events", () => {
|
||||
const worker = {
|
||||
session_id: "child-session",
|
||||
name: "child",
|
||||
parent_session_id: "parent-session",
|
||||
kind: "sub_worker" as const,
|
||||
};
|
||||
const nestedWorker = {
|
||||
session_id: "grandchild-session",
|
||||
name: "grandchild",
|
||||
parent_session_id: "child-session",
|
||||
kind: "sub_worker" as const,
|
||||
};
|
||||
const projector = createConsoleProjector();
|
||||
let projection = projector.append([{
|
||||
eventId: "child",
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 2,
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker: nestedWorker,
|
||||
revision: 1,
|
||||
event: { event: "text_done", data: { text: "nested" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}]);
|
||||
assertEquals(projection.internalWorkers.length, 1);
|
||||
assertEquals(
|
||||
projection.internalWorkers[0].console.internalWorkers.length,
|
||||
1,
|
||||
);
|
||||
|
||||
projection = projector.append([{
|
||||
eventId: "removed",
|
||||
event: {
|
||||
event: "internal_worker_removed",
|
||||
data: { worker, revision: 3 },
|
||||
},
|
||||
}, {
|
||||
eventId: "late",
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 4,
|
||||
event: { event: "text_done", data: { text: "must stay removed" } },
|
||||
},
|
||||
},
|
||||
}]);
|
||||
assertEquals(projection.internalWorkers, []);
|
||||
|
||||
const snapshot = snapshotEvent("/repo");
|
||||
projection = projector.append([{ eventId: "snapshot", event: snapshot }]);
|
||||
assertEquals(projection.internalWorkers, []);
|
||||
assertEquals(projection.removedInternalWorkers, {});
|
||||
});
|
||||
|
||||
Deno.test("stale Internal Worker removal cannot discard a newer projection", () => {
|
||||
const worker = {
|
||||
session_id: "child-session",
|
||||
name: "child",
|
||||
parent_session_id: "parent-session",
|
||||
kind: "sub_worker" as const,
|
||||
};
|
||||
const projector = createConsoleProjector();
|
||||
projector.append([{
|
||||
eventId: "current",
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 4,
|
||||
event: { event: "text_done", data: { text: "current" } },
|
||||
},
|
||||
},
|
||||
}]);
|
||||
|
||||
const projection = projector.append([{
|
||||
eventId: "stale-removal",
|
||||
event: {
|
||||
event: "internal_worker_removed",
|
||||
data: { worker, revision: 3 },
|
||||
},
|
||||
}]);
|
||||
assertEquals(projection.internalWorkers.length, 1);
|
||||
assertEquals(projection.internalWorkers[0].revision, 4);
|
||||
});
|
||||
|
||||
Deno.test("snapshot restores TaskStore state from system history", () => {
|
||||
const taskSnapshot =
|
||||
`[Session TaskStore snapshot]\n\n\`\`\`json\n{\n "tasks": [{"taskid": 3, "status": "pending", "subject": "Restored", "description": "From compaction"}]\n}\n\`\`\``;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
Alert,
|
||||
CommandEvent,
|
||||
CommandSnapshot,
|
||||
CommandStreamSlice,
|
||||
Event as ProtocolEvent,
|
||||
InFlightBlock,
|
||||
InFlightToolCallState,
|
||||
@@ -52,6 +55,7 @@ type ToolCallView = {
|
||||
output?: string | null;
|
||||
isError?: boolean;
|
||||
cwd?: string | null;
|
||||
command?: CommandSnapshot;
|
||||
};
|
||||
|
||||
export type ConsoleDiffLine = {
|
||||
@@ -111,6 +115,8 @@ export type ConsoleProjection = {
|
||||
cwd: string | null;
|
||||
lastEventId: string | null;
|
||||
internalWorkers: InternalWorkerProjection[];
|
||||
/** Terminal child-session fences, reset only by an authoritative snapshot. */
|
||||
removedInternalWorkers: Record<string, number>;
|
||||
};
|
||||
|
||||
export type ConsoleTimelineLineSelection = {
|
||||
@@ -197,6 +203,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
|
||||
cwd: null,
|
||||
lastEventId: null,
|
||||
internalWorkers: [],
|
||||
removedInternalWorkers: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -450,6 +457,135 @@ function appendSnapshotInFlightLines(
|
||||
});
|
||||
}
|
||||
|
||||
const COMMAND_STREAM_DISPLAY_BYTES = 32 * 1024;
|
||||
|
||||
function appendSnapshotCommands(
|
||||
projection: ConsoleProjection,
|
||||
commands: CommandSnapshot[],
|
||||
eventId: string,
|
||||
): void {
|
||||
commands.forEach((command) => upsertCommandSnapshot(projection, eventId, command));
|
||||
}
|
||||
|
||||
function upsertCommandSnapshot(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
command: CommandSnapshot,
|
||||
): void {
|
||||
const toolCallId = command.tool_call_id ?? `command:${command.command_id}`;
|
||||
const existingIndex = findToolCallLineIndex(projection, toolCallId);
|
||||
const existing = existingIndex >= 0
|
||||
? projection.lines[existingIndex].toolCall
|
||||
: undefined;
|
||||
upsertToolCall(projection, eventId, toolCallId, {
|
||||
name: existing?.name ?? "Bash",
|
||||
state: existing?.state ?? "running",
|
||||
command,
|
||||
});
|
||||
}
|
||||
|
||||
function applyCommandEvent(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
event: CommandEvent,
|
||||
): void {
|
||||
if (event.kind === "started") {
|
||||
upsertCommandSnapshot(projection, eventId, {
|
||||
command_id: event.command_id,
|
||||
tool_call_id: event.tool_call_id,
|
||||
status: "running",
|
||||
started_at_ms: event.observed_at_ms,
|
||||
observed_at_ms: event.observed_at_ms,
|
||||
last_output_at_ms: null,
|
||||
stdout: emptyCommandStream(),
|
||||
stderr: emptyCommandStream(),
|
||||
exit_code: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const index = projection.lines.findIndex((line) =>
|
||||
line.toolCall?.command?.command_id === event.command_id
|
||||
);
|
||||
if (index < 0) {
|
||||
if (event.kind === "output") {
|
||||
const stream = commandStreamFromEvent(event);
|
||||
upsertCommandSnapshot(projection, eventId, {
|
||||
command_id: event.command_id,
|
||||
tool_call_id: null,
|
||||
status: "running",
|
||||
started_at_ms: event.observed_at_ms,
|
||||
observed_at_ms: event.observed_at_ms,
|
||||
last_output_at_ms: event.observed_at_ms,
|
||||
stdout: event.stream === "stdout" ? stream : emptyCommandStream(),
|
||||
stderr: event.stream === "stderr" ? stream : emptyCommandStream(),
|
||||
exit_code: null,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = projection.lines[index].toolCall!.command!;
|
||||
if (event.kind === "terminal") {
|
||||
upsertCommandSnapshot(projection, eventId, {
|
||||
...existing,
|
||||
status: event.status,
|
||||
exit_code: event.exit_code,
|
||||
observed_at_ms: event.observed_at_ms,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const updatedStream = appendCommandStream(
|
||||
event.stream === "stdout" ? existing.stdout : existing.stderr,
|
||||
event.start_offset,
|
||||
event.end_offset,
|
||||
event.content,
|
||||
);
|
||||
upsertCommandSnapshot(projection, eventId, {
|
||||
...existing,
|
||||
observed_at_ms: event.observed_at_ms,
|
||||
last_output_at_ms: event.observed_at_ms,
|
||||
stdout: event.stream === "stdout" ? updatedStream : existing.stdout,
|
||||
stderr: event.stream === "stderr" ? updatedStream : existing.stderr,
|
||||
});
|
||||
}
|
||||
|
||||
function emptyCommandStream(): CommandStreamSlice {
|
||||
return { start_offset: 0, end_offset: 0, content: "", truncated: false };
|
||||
}
|
||||
|
||||
function commandStreamFromEvent(
|
||||
event: Extract<CommandEvent, { kind: "output" }>,
|
||||
): CommandStreamSlice {
|
||||
return appendCommandStream(
|
||||
emptyCommandStream(),
|
||||
event.start_offset,
|
||||
event.end_offset,
|
||||
event.content,
|
||||
);
|
||||
}
|
||||
|
||||
function appendCommandStream(
|
||||
existing: CommandStreamSlice,
|
||||
startOffset: number,
|
||||
endOffset: number,
|
||||
content: string,
|
||||
): CommandStreamSlice {
|
||||
if (endOffset <= existing.end_offset) return existing;
|
||||
const contiguous = startOffset === existing.end_offset;
|
||||
const combined = contiguous ? `${existing.content}${content}` : content;
|
||||
const tail = combined.length > COMMAND_STREAM_DISPLAY_BYTES
|
||||
? combined.slice(-COMMAND_STREAM_DISPLAY_BYTES)
|
||||
: combined;
|
||||
return {
|
||||
start_offset: endOffset - tail.length,
|
||||
end_offset: endOffset,
|
||||
content: tail,
|
||||
truncated: existing.truncated || !contiguous || tail.length < combined.length ||
|
||||
startOffset > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function projectInternalWorkerSnapshot(
|
||||
snapshot: InternalWorkerSnapshot,
|
||||
eventId: string,
|
||||
@@ -467,6 +603,11 @@ function projectInternalWorkerSnapshot(
|
||||
`${eventId}:internal:${snapshot.worker.session_id}:in-flight`,
|
||||
cwd,
|
||||
);
|
||||
appendSnapshotCommands(
|
||||
console,
|
||||
snapshot.in_flight?.commands ?? [],
|
||||
`${eventId}:internal:${snapshot.worker.session_id}:command`,
|
||||
);
|
||||
if (snapshot.error) {
|
||||
console.lines.push({
|
||||
id: `${eventId}:internal:${snapshot.worker.session_id}:error`,
|
||||
@@ -503,6 +644,7 @@ export function applyProtocolEvent(
|
||||
cwd: projection.cwd,
|
||||
lastEventId: envelope.eventId,
|
||||
internalWorkers: [...projection.internalWorkers],
|
||||
removedInternalWorkers: { ...projection.removedInternalWorkers },
|
||||
};
|
||||
|
||||
switch (event.event) {
|
||||
@@ -619,12 +761,24 @@ export function applyProtocolEvent(
|
||||
`${envelope.eventId}:snapshot-in-flight`,
|
||||
next.cwd,
|
||||
);
|
||||
appendSnapshotCommands(
|
||||
next,
|
||||
event.data.in_flight?.commands ?? [],
|
||||
`${envelope.eventId}:snapshot-command`,
|
||||
);
|
||||
next.internalWorkers = (event.data.internal_workers ?? []).map((worker) =>
|
||||
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
|
||||
);
|
||||
next.removedInternalWorkers = {};
|
||||
break;
|
||||
}
|
||||
case "internal_worker": {
|
||||
if (
|
||||
Object.hasOwn(
|
||||
next.removedInternalWorkers,
|
||||
event.data.worker.session_id,
|
||||
)
|
||||
) break;
|
||||
const existingIndex = next.internalWorkers.findIndex((worker) =>
|
||||
worker.worker.session_id === event.data.worker.session_id
|
||||
);
|
||||
@@ -650,9 +804,25 @@ export function applyProtocolEvent(
|
||||
else next.internalWorkers.push(updated);
|
||||
break;
|
||||
}
|
||||
case "internal_worker_removed": {
|
||||
const existingIndex = next.internalWorkers.findIndex((worker) =>
|
||||
worker.worker.session_id === event.data.worker.session_id
|
||||
);
|
||||
const existingRevision = existingIndex >= 0
|
||||
? next.internalWorkers[existingIndex].revision
|
||||
: 0;
|
||||
if (event.data.revision <= existingRevision) break;
|
||||
next.removedInternalWorkers[event.data.worker.session_id] =
|
||||
event.data.revision;
|
||||
if (existingIndex >= 0) next.internalWorkers.splice(existingIndex, 1);
|
||||
break;
|
||||
}
|
||||
case "status":
|
||||
next.status = event.data.status;
|
||||
break;
|
||||
case "command":
|
||||
applyCommandEvent(next, envelope.eventId, event.data.event);
|
||||
break;
|
||||
case "segment_rotated": {
|
||||
const retainedErrors = next.lines.filter((line) => line.kind === "error");
|
||||
const segment = snapshotProjectionFromEntries(
|
||||
@@ -1030,6 +1200,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
||||
if (!toolCall) {
|
||||
return item;
|
||||
}
|
||||
const commandTerminal = toolCall.command !== undefined &&
|
||||
toolCall.command.status !== "running";
|
||||
const commandError = toolCall.command !== undefined &&
|
||||
["failed", "timed_out", "cancelled"].includes(toolCall.command.status);
|
||||
return {
|
||||
...item,
|
||||
title: item.title.startsWith("Call · Tool result")
|
||||
@@ -1038,8 +1212,8 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
||||
body: renderToolCall(toolCall),
|
||||
detail: toolCallDetail(toolCall),
|
||||
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
||||
streaming: !["done", "error"].includes(toolCall.state),
|
||||
error: toolCall.state === "error",
|
||||
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
||||
error: toolCall.state === "error" || commandError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1290,9 +1464,57 @@ function renderBashTool(toolCall: ToolCallView): string {
|
||||
const args = parsedArgs(toolCall);
|
||||
const command = stringField(args, "command");
|
||||
return compactLines([
|
||||
`Bash — ${stateSuffix(toolCall.state)}`,
|
||||
`Bash — ${commandStateSuffix(toolCall)}`,
|
||||
command ? `$ ${command}` : argsText(toolCall),
|
||||
cappedDisplaySection(resultText(toolCall), 10),
|
||||
commandTiming(toolCall.command),
|
||||
["done", "error"].includes(toolCall.state)
|
||||
? cappedDisplaySection(resultText(toolCall), 10)
|
||||
: renderLiveCommandOutput(toolCall.command),
|
||||
]);
|
||||
}
|
||||
|
||||
function commandStateSuffix(toolCall: ToolCallView): string {
|
||||
const command = toolCall.command;
|
||||
if (!command) return stateSuffix(toolCall.state);
|
||||
if (command.status === "completed") {
|
||||
return command.exit_code === null
|
||||
? "completed"
|
||||
: `completed (exit ${command.exit_code})`;
|
||||
}
|
||||
if (command.status === "failed") {
|
||||
return command.exit_code === null ? "failed" : `failed (exit ${command.exit_code})`;
|
||||
}
|
||||
if (command.status === "timed_out") return "timed out";
|
||||
if (command.status === "cancelled") return "cancelled";
|
||||
return "running…";
|
||||
}
|
||||
|
||||
function commandTiming(command?: CommandSnapshot): string | undefined {
|
||||
if (!command) return undefined;
|
||||
const elapsed = Math.max(0, command.observed_at_ms - command.started_at_ms);
|
||||
if (command.status !== "running") return `elapsed ${durationLabel(elapsed)}`;
|
||||
if (command.last_output_at_ms === null) {
|
||||
return `elapsed ${durationLabel(elapsed)} · awaiting first output`;
|
||||
}
|
||||
const lastOutputElapsed = Math.max(
|
||||
0,
|
||||
command.last_output_at_ms - command.started_at_ms,
|
||||
);
|
||||
return `elapsed ${durationLabel(elapsed)} · last output at +${durationLabel(lastOutputElapsed)}`;
|
||||
}
|
||||
|
||||
function durationLabel(milliseconds: number): string {
|
||||
if (milliseconds < 1000) return `${milliseconds}ms`;
|
||||
return `${(milliseconds / 1000).toFixed(milliseconds < 10_000 ? 1 : 0)}s`;
|
||||
}
|
||||
|
||||
function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined {
|
||||
if (!command) return undefined;
|
||||
return compactLines([
|
||||
command.stdout.truncated ? "[stdout tail; earlier output omitted]" : undefined,
|
||||
command.stdout.content ? `stdout:\n${command.stdout.content}` : undefined,
|
||||
command.stderr.truncated ? "[stderr tail; earlier output omitted]" : undefined,
|
||||
command.stderr.content ? `stderr:\n${command.stderr.content}` : undefined,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1545,6 +1767,7 @@ function snapshotProjectionFromEntries(
|
||||
cwd,
|
||||
lastEventId: eventId,
|
||||
internalWorkers: [],
|
||||
removedInternalWorkers: {},
|
||||
};
|
||||
entries.forEach((entry, index) =>
|
||||
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)
|
||||
|
||||
@@ -42,6 +42,13 @@ export function workspaceMultiplexer(workspaceId: string): WorkspaceMultiplexer
|
||||
return multiplexer;
|
||||
}
|
||||
|
||||
export function disposeWorkspaceMultiplexer(workspaceId: string): void {
|
||||
const multiplexer = multiplexers.get(workspaceId);
|
||||
if (!multiplexer) return;
|
||||
multiplexers.delete(workspaceId);
|
||||
multiplexer.dispose();
|
||||
}
|
||||
|
||||
export class WorkspaceMultiplexer {
|
||||
readonly #workspaceId: string;
|
||||
readonly #subscriptions = new Map<string, ActiveSubscription>();
|
||||
@@ -219,6 +226,22 @@ export class WorkspaceMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.#closed = true;
|
||||
if (this.#reconnectTimer) {
|
||||
clearTimeout(this.#reconnectTimer);
|
||||
this.#reconnectTimer = null;
|
||||
}
|
||||
for (const subscription of this.#subscriptions.values()) {
|
||||
subscription.listener.onStatus?.('closed', 'Workspace selection changed');
|
||||
}
|
||||
this.#subscriptions.clear();
|
||||
this.#requests.clear();
|
||||
this.#runtimeSubscriptions.clear();
|
||||
this.#socket?.close();
|
||||
this.#socket = null;
|
||||
}
|
||||
|
||||
#send(frame: SubscriptionFrame): void {
|
||||
if (this.#socket?.readyState !== WebSocket.OPEN) return;
|
||||
this.#socket.send(JSON.stringify(frame));
|
||||
|
||||
@@ -14,7 +14,10 @@ export type WorkspaceProfileApi = {
|
||||
getProfiles(workspaceId: string): Promise<ProfileSettingsResponse>;
|
||||
};
|
||||
|
||||
async function requestJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
async function requestJson<T>(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await fetch(input, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`request failed: ${response.status}`);
|
||||
@@ -44,7 +47,9 @@ export async function updateWorkspaceMetadataSettings(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchProfileSettings(workspaceId: string): Promise<ProfileSettingsResponse> {
|
||||
export async function fetchProfileSettings(
|
||||
workspaceId: string,
|
||||
): Promise<ProfileSettingsResponse> {
|
||||
return await requestJson<ProfileSettingsResponse>(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
|
||||
);
|
||||
@@ -52,20 +57,12 @@ export async function fetchProfileSettings(workspaceId: string): Promise<Profile
|
||||
|
||||
export function createWorkspaceProfileApi(): WorkspaceProfileApi {
|
||||
return {
|
||||
async getMetadata(workspaceId) {
|
||||
return await requestJson<WorkspaceMetadataSettingsResponse>(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`,
|
||||
);
|
||||
},
|
||||
getMetadata: fetchWorkspaceMetadataSettings,
|
||||
async updateMetadata(workspaceId, displayName, expectedRevision) {
|
||||
return await requestJson<WorkspaceMetadataMutationResponse>(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ display_name: displayName, expected_revision: expectedRevision }),
|
||||
},
|
||||
);
|
||||
return await updateWorkspaceMetadataSettings(workspaceId, {
|
||||
display_name: displayName,
|
||||
revision: expectedRevision,
|
||||
});
|
||||
},
|
||||
getProfiles: fetchProfileSettings,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
const { currentPath }: Props = $props();
|
||||
|
||||
const items = [
|
||||
{ href: '/', label: 'Workspaces' },
|
||||
{ href: '/#workspace-create-title', label: 'Create Workspace' },
|
||||
{ href: '/account', label: 'Account' },
|
||||
{ href: '/login/device', label: 'Device Login' },
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||
import TicketsNavSection from './TicketsNavSection.svelte';
|
||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
||||
import type { RepositoryListResponse, WorkspaceResponse } from './types';
|
||||
|
||||
type Props = {
|
||||
@@ -76,6 +77,8 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if workspaceId}<WorkspaceSwitcher currentWorkspaceId={workspaceId} />{/if}
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
||||
<TicketsNavSection {currentPath} {workspaceId} />
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
listWorkspaces,
|
||||
type WorkspaceCatalogRecord,
|
||||
} from "$lib/workspace/api/workspace-catalog";
|
||||
import "$lib/workspace/styles/workspace-catalog.css";
|
||||
|
||||
let { currentWorkspaceId } = $props<{ currentWorkspaceId: string }>();
|
||||
let workspaces = $state<WorkspaceCatalogRecord[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
workspaces = await listWorkspaces(fetch);
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function switchWorkspace(event: Event) {
|
||||
const workspaceId = (event.currentTarget as HTMLSelectElement).value;
|
||||
if (!workspaceId || workspaceId === currentWorkspaceId) return;
|
||||
await goto(`/w/${encodeURIComponent(workspaceId)}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="workspace-switcher">
|
||||
<label for="workspace-switcher-select">Workspace</label>
|
||||
<select
|
||||
id="workspace-switcher-select"
|
||||
value={currentWorkspaceId}
|
||||
onchange={switchWorkspace}
|
||||
disabled={loading}
|
||||
aria-label="Switch Workspace"
|
||||
>
|
||||
{#if !workspaces.some((workspace) => workspace.workspace_id === currentWorkspaceId)}
|
||||
<option value={currentWorkspaceId}>
|
||||
{loading ? "Loading current Workspace…" : "Current Workspace unavailable"}
|
||||
</option>
|
||||
{/if}
|
||||
{#each workspaces as workspace (workspace.workspace_id)}
|
||||
<option value={workspace.workspace_id}>{workspace.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="workspace-switcher-actions">
|
||||
<a href="/">All Workspaces</a>
|
||||
<a href="/#workspace-create-title">Create</a>
|
||||
</div>
|
||||
{#if error}<span class="workspace-switcher-error">Selector unavailable: {error}</span>{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,209 @@
|
||||
@layer components {
|
||||
.workspace-catalog-shell {
|
||||
width: min(1120px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 3rem 0 5rem;
|
||||
display: grid;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.workspace-catalog-heading,
|
||||
.workspace-card-heading,
|
||||
.workspace-create-row,
|
||||
.workspace-switcher-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.workspace-catalog-heading h1,
|
||||
.workspace-create-panel h2,
|
||||
.workspace-catalog-shell h2 {
|
||||
margin: 0.2rem 0 0.45rem;
|
||||
}
|
||||
|
||||
.workspace-catalog-heading p,
|
||||
.workspace-create-panel p,
|
||||
.workspace-empty-state p {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workspace-catalog-eyebrow {
|
||||
color: var(--accent) !important;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workspace-catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.workspace-catalog-card,
|
||||
.workspace-create-panel,
|
||||
.workspace-empty-state {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.workspace-catalog-card {
|
||||
color: inherit;
|
||||
padding: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.workspace-catalog-card:hover,
|
||||
.workspace-catalog-card:focus-visible {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.workspace-catalog-card code,
|
||||
.workspace-catalog-card small,
|
||||
.workspace-repository-summary small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.workspace-card-heading > span {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workspace-card-heading > .workspace-state-active {
|
||||
border-color: color-mix(in srgb, var(--success) 50%, var(--line));
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.workspace-repository-summary {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.workspace-empty-state {
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.workspace-create-panel {
|
||||
padding: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr);
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.workspace-create-panel form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.workspace-create-panel label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.workspace-create-panel input,
|
||||
.workspace-switcher select {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 0.65rem 0.75rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.workspace-create-row > label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workspace-primary-action,
|
||||
.workspace-secondary-action {
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-primary-action {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.workspace-secondary-action {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workspace-primary-action:disabled,
|
||||
.workspace-secondary-action:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.workspace-catalog-alert {
|
||||
border-left: 3px solid var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||
padding: 0.75rem 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workspace-switcher {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0 0.75rem 0.85rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.workspace-switcher label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workspace-switcher select {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.workspace-switcher-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.workspace-switcher-error {
|
||||
color: var(--danger);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.workspace-create-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace-create-row,
|
||||
.workspace-catalog-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,5 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceRoute } from "$lib/workspace/api/http";
|
||||
import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export const load: LayoutLoad = async ({ fetch, params, url }) => {
|
||||
if (params.workspaceId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const publicRoutes = new Set(["/account", "/login/device"]);
|
||||
if (publicRoutes.has(url.pathname)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
|
||||
if (workspace.data) {
|
||||
const scopedPath = workspaceRoute(workspace.data.workspace_id);
|
||||
throw redirect(307, `${scopedPath}${url.search}`);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
// Workspace selection is explicit at `/`; the root layout must never infer a
|
||||
// singleton Workspace or redirect based on an unscoped compatibility endpoint.
|
||||
export const load: LayoutLoad = () => ({});
|
||||
|
||||
@@ -1,6 +1,189 @@
|
||||
<main class="workspace-panel-shell">
|
||||
<section class="workspace-card">
|
||||
<h1>Redirecting to scoped workspace…</h1>
|
||||
<p class="section-note">The workspace entry bootstraps the current workspace id and opens the canonical <code>/w/<workspace-id></code> route.</p>
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
createOperationKey,
|
||||
createWorkspace,
|
||||
creationErrorMessage,
|
||||
loadWorkspaceCatalog,
|
||||
type CreateWorkspaceRequest,
|
||||
type WorkspaceCatalogItem,
|
||||
} from "$lib/workspace/api/workspace-catalog";
|
||||
import "$lib/workspace/styles/workspace-catalog.css";
|
||||
|
||||
let { data } = $props();
|
||||
let workspaces = $state<WorkspaceCatalogItem[]>([]);
|
||||
let catalogError = $state<string | null>(null);
|
||||
let refreshing = $state(false);
|
||||
let creating = $state(false);
|
||||
let creationError = $state<string | null>(null);
|
||||
let displayName = $state("");
|
||||
let repositoryUri = $state("");
|
||||
let repositoryName = $state("Main");
|
||||
let defaultRef = $state("");
|
||||
let lastSubmission = $state<{
|
||||
signature: string;
|
||||
request: CreateWorkspaceRequest;
|
||||
} | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
workspaces = data.workspaces;
|
||||
catalogError = data.catalogError;
|
||||
});
|
||||
|
||||
async function refreshCatalog() {
|
||||
refreshing = true;
|
||||
catalogError = null;
|
||||
try {
|
||||
workspaces = await loadWorkspaceCatalog(fetch);
|
||||
} catch (error) {
|
||||
catalogError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreation(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (creating) return;
|
||||
const normalized = {
|
||||
displayName: displayName.trim(),
|
||||
repositoryUri: repositoryUri.trim(),
|
||||
repositoryName: repositoryName.trim(),
|
||||
defaultRef: defaultRef.trim(),
|
||||
};
|
||||
const signature = JSON.stringify(normalized);
|
||||
const request = lastSubmission?.signature === signature
|
||||
? lastSubmission.request
|
||||
: {
|
||||
operation_key: createOperationKey(),
|
||||
display_name: normalized.displayName,
|
||||
repository: {
|
||||
uri: normalized.repositoryUri,
|
||||
display_name: normalized.repositoryName || null,
|
||||
default_ref: normalized.defaultRef || null,
|
||||
},
|
||||
};
|
||||
lastSubmission = { signature, request };
|
||||
creating = true;
|
||||
creationError = null;
|
||||
try {
|
||||
const response = await createWorkspace(fetch, request);
|
||||
await goto(`/w/${encodeURIComponent(response.workspace.workspace_id)}`);
|
||||
} catch (error) {
|
||||
creationError = creationErrorMessage(error);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdated(value: string): string {
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp)
|
||||
? value
|
||||
: new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(timestamp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Workspaces · Yoi</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="workspace-catalog-shell">
|
||||
<section class="workspace-catalog-heading">
|
||||
<div>
|
||||
<p class="workspace-catalog-eyebrow">Backend</p>
|
||||
<h1>Workspaces</h1>
|
||||
<p>Select an accessible team space or create one on this Backend.</p>
|
||||
</div>
|
||||
<button class="workspace-secondary-action" onclick={refreshCatalog} disabled={refreshing}>
|
||||
{refreshing ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{#if catalogError}
|
||||
<div class="workspace-catalog-alert" role="alert">
|
||||
Refresh failed. Existing results were kept. {catalogError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section aria-labelledby="workspace-list-title">
|
||||
<h2 id="workspace-list-title">Available Workspaces</h2>
|
||||
{#if workspaces.length === 0}
|
||||
<div class="workspace-empty-state">
|
||||
<strong>No accessible Workspaces</strong>
|
||||
<p>Create the first Workspace if you have Backend permission.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="workspace-catalog-grid">
|
||||
{#each workspaces as workspace (workspace.workspace_id)}
|
||||
<a
|
||||
class="workspace-catalog-card"
|
||||
href={`/w/${encodeURIComponent(workspace.workspace_id)}`}
|
||||
>
|
||||
<span class="workspace-card-heading">
|
||||
<strong>{workspace.display_name}</strong>
|
||||
<span class:workspace-state-active={workspace.state === "active"}>
|
||||
{workspace.state}
|
||||
</span>
|
||||
</span>
|
||||
<code>{workspace.workspace_id}</code>
|
||||
{#if workspace.repositories[0]}
|
||||
<span class="workspace-repository-summary">
|
||||
{workspace.repositories[0].name}
|
||||
<small>
|
||||
{workspace.repositories[0].default_ref ?? "repository default"} ·
|
||||
{workspace.repositories[0].kind}
|
||||
</small>
|
||||
</span>
|
||||
{:else if workspace.repository_error}
|
||||
<small>Repository summary unavailable</small>
|
||||
{:else}
|
||||
<small>No repositories</small>
|
||||
{/if}
|
||||
<small>Updated {formatUpdated(workspace.updated_at)}</small>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="workspace-create-panel" aria-labelledby="workspace-create-title">
|
||||
<div>
|
||||
<p class="workspace-catalog-eyebrow">New team space</p>
|
||||
<h2 id="workspace-create-title">Create Workspace</h2>
|
||||
<p>
|
||||
Repository paths and URIs are interpreted by the Backend. Browser-local paths are
|
||||
not authority.
|
||||
</p>
|
||||
</div>
|
||||
<form onsubmit={submitCreation}>
|
||||
<label>
|
||||
Workspace display name
|
||||
<input bind:value={displayName} required autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Initial repository absolute path or URI
|
||||
<input bind:value={repositoryUri} required autocomplete="off" />
|
||||
</label>
|
||||
<div class="workspace-create-row">
|
||||
<label>
|
||||
Repository display name
|
||||
<input bind:value={repositoryName} autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Default ref
|
||||
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
{#if creationError}
|
||||
<div class="workspace-catalog-alert" role="alert">{creationError}</div>
|
||||
{/if}
|
||||
<button class="workspace-primary-action" type="submit" disabled={creating}>
|
||||
{creating ? "Creating…" : creationError ? "Retry creation" : "Create Workspace"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
import { loadWorkspaceCatalog } from "$lib/workspace/api/workspace-catalog";
|
||||
|
||||
export const load: PageLoad = async () => ({});
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
try {
|
||||
return {
|
||||
workspaces: await loadWorkspaceCatalog(fetch),
|
||||
catalogError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
workspaces: [],
|
||||
catalogError: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import "$lib/workspace/styles/workspace-catalog.css";
|
||||
|
||||
const workspaceId = $derived(page.params.workspaceId ?? "unknown");
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Workspace unavailable · Yoi</title></svelte:head>
|
||||
|
||||
<div class="workspace-catalog-shell">
|
||||
<section class="workspace-empty-state">
|
||||
<p class="workspace-catalog-eyebrow">Workspace unavailable</p>
|
||||
<h1>The selected Workspace cannot be opened</h1>
|
||||
<p>
|
||||
<code>{workspaceId}</code> may have been removed, become inaccessible, or no longer exist on
|
||||
this Backend. No state from a previously selected Workspace was retained.
|
||||
</p>
|
||||
<div class="workspace-switcher-actions">
|
||||
<a href="/">Choose another Workspace</a>
|
||||
<a href="/#workspace-create-title">Create Workspace</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -3,6 +3,7 @@
|
||||
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
|
||||
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
|
||||
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
||||
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
||||
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
||||
import '$lib/workspace/styles/workspace-pages.css';
|
||||
import '$lib/workspace/styles/tickets.css';
|
||||
@@ -10,6 +11,11 @@
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
$effect(() => {
|
||||
const workspaceId = data.workspace?.workspace_id;
|
||||
if (!workspaceId) return;
|
||||
return () => disposeWorkspaceMultiplexer(workspaceId);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet workspaceHeader()}
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
import type {
|
||||
RepositoryListResponse,
|
||||
WorkspaceResponse,
|
||||
} from "$lib/workspace/sidebar/types";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
|
||||
export const load: LayoutLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
|
||||
const [workspace, repositories] = await Promise.all([
|
||||
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
|
||||
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
|
||||
loadJson<WorkspaceResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/workspace"),
|
||||
),
|
||||
loadJson<RepositoryListResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/repositories"),
|
||||
),
|
||||
]);
|
||||
|
||||
if (!workspace.data) {
|
||||
error(404, {
|
||||
message: workspace.error ?? `Workspace ${workspaceId} is unavailable`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
workspace: workspace.data,
|
||||
workspaceError: workspace.error,
|
||||
workspaceError: null,
|
||||
repositories: repositories.data,
|
||||
repositoriesError: repositories.error,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user