Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions packages/client/workbench/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,23 @@ app-specific entries (`apps/desktop`, `apps/webview`) and pure presentation (`pa
the workbench **binding** — it pins the generic to `LinkCodeSdkClient`, promotes each
generation into the ambient default tayori reads (`setDefaultClient`), and reports outcomes to
product analytics. Behavior changes belong in client-core; only SDK/analytics wiring belongs here. SWR retains cached data across generations of the same
endpoint, starts a fresh cache after endpoint migration, and revalidates once after a generation
becomes protocol-ready; it does not own connection state.
endpoint, starts a fresh cache after endpoint migration, revalidates once after a generation
becomes protocol-ready, and revalidates the session and workspace list caches on every
`session.changed` push, coalesced through `coalesceRuns` (the daemon registers/freshens a
session's workspace *before* announcing the record on start and resume, so one frame covers both
lists there; an import of a brand-new cwd announces before the touch, and another client's
explicit `workspace.register` / rename / archive has no push at all, so both wait for the next
revalidation). Coalescing is not optional: one start emits several frames, a bulk import emits one
per entry, and SWR's key-filter `mutate` deletes its own dedupe markers, so an uncoalesced
subscription turns a burst into one forced round trip per frame per list. The effect's abort
signal prevents queued runs after generation teardown; a fresh controller snapshot gates
revalidation while a disposed generation remains mounted during recovery. This coalescer stays workbench-local:
SWR owns fetch errors here; client-core's direct refresh loop has different failure semantics.
It does not own connection state.
- `mock/` — the dev mock announces imports before touching the workspace, matching the engine's order,
but its synchronous touch cannot reproduce the engine's async import race. Mock tests prove
start/resume-driven revalidation only. The mock has no `session.delete` handler and therefore no
`session.changed` `removed` emission; deletion-driven revalidation needs separate coverage.
- `surface/` — the workbench feature surface: the `Workbench` component, the `WorkbenchShell*`
contract plus the default shell, and session orchestration hooks.
- `terminal/` — the daemon-backed interactive terminal: the panel container, the key-scoped
Expand Down
16 changes: 14 additions & 2 deletions packages/client/workbench/src/mock/dev-mock-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,9 +908,11 @@ export class DevMockHost {
model,
effort,
});
// Parity with the engine: starting a session registers/freshens its directory's workspace.
// Parity with the engine: starting a session registers/freshens its directory's workspace,
// then announces the record before answering the request.
this.touchWorkspace(cwd, now);
const { sessionId } = session;
this.send({ kind: 'session.changed', sessionId, reason: 'created' });
this.emit(sessionId, { type: 'status', status: 'starting' });
this.emit(sessionId, { type: 'current-mode-update', currentModeId: 'mock' });
this.emitDirectiveAdvertisement(sessionId);
Expand Down Expand Up @@ -954,6 +956,10 @@ export class DevMockHost {
updatedAt: now,
origin,
});
// Engine order, deliberately: importRecord announces the record and only then touches the
// workspace, unlike start/resume which register it first.
this.send({ kind: 'session.changed', sessionId: session.sessionId, reason: 'created' });
Comment thread
Zerlight marked this conversation as resolved.
this.touchWorkspace(session.cwd, now);
this.send({
kind: 'session.imported',
replyTo,
Expand Down Expand Up @@ -1084,6 +1090,9 @@ export class DevMockHost {
return;
}
session.status = 'idle';
this.touchWorkspace(session.cwd, Date.now());
// Parity with the engine: a relaunch appends a run, which re-points the listed identity.
this.send({ kind: 'session.changed', sessionId, reason: 'updated' });
this.attachSession(sessionId);
this.send({ kind: 'session.started', replyTo, sessionId });
}
Expand Down Expand Up @@ -1239,7 +1248,10 @@ export class DevMockHost {
content: ContentBlock[],
): Promise<void> {
const text = promptText(content);
if (text && !session.title) session.title = text.slice(0, 80);
if (text && !session.title) {
session.title = text.slice(0, 80);
this.send({ kind: 'session.changed', sessionId: session.sessionId, reason: 'updated' });
}
session.status = 'running';
this.emit(session.sessionId, {
type: 'user-message',
Expand Down
79 changes: 79 additions & 0 deletions packages/client/workbench/src/runtime/__tests__/coalesce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { wait } from 'foxts/wait';
import { expect, it, vi } from 'vitest';
import { coalesceRuns } from '../coalesce';
import { deferred } from './connection-controller-test-helpers';

it('collapses a burst arriving mid-run into a single trailing run', async () => {
const gates = [deferred(), deferred()];
let started = 0;
const trigger = coalesceRuns(() => {
const gate = gates[started] ?? deferred();
started += 1;
return gate.promise;
}, new AbortController().signal);

trigger();
expect(started).toBe(1);

// Three more frames while the first run is still in flight: they must collapse into one.
trigger();
trigger();
trigger();
expect(started).toBe(1);

gates[0].resolve();
await vi.waitFor(() => expect(started).toBe(2));

gates[1].resolve();
await wait(0);
expect(started).toBe(2);
});

it('runs again for a trigger that arrives after the previous run settled', async () => {
let started = 0;
const trigger = coalesceRuns(() => {
started += 1;
return Promise.resolve();
}, new AbortController().signal);

trigger();
await wait(0);
trigger();
await wait(0);

expect(started).toBe(2);
});

it('keeps draining after a failed run', async () => {
let started = 0;
const trigger = coalesceRuns(() => {
started += 1;
return started === 1 ? Promise.reject(new Error('fetch failed')) : Promise.resolve();
}, new AbortController().signal);

trigger();
trigger();
await vi.waitFor(() => expect(started).toBe(2));
});

it.each(['resolve', 'reject'] as const)(
'drops queued work after abort when the in-flight run settles via %s',
async (outcome) => {
const controller = new AbortController();
const gate = deferred();
const run = vi.fn(() => gate.promise);
const trigger = coalesceRuns(run, controller.signal);

trigger();
trigger();
expect(run).toHaveBeenCalledTimes(1);
controller.abort();
if (outcome === 'resolve') gate.resolve();
else gate.reject(new Error('client disposed'));
await wait(0);
expect(run).toHaveBeenCalledTimes(1);

trigger();
expect(run).toHaveBeenCalledTimes(1);
},
);
39 changes: 39 additions & 0 deletions packages/client/workbench/src/runtime/coalesce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { noop } from 'foxts/noop';

/**
* Mid-run triggers need one trailing run; abort discards it without cancelling active work.
* The caller owns error reporting; a failed run still drains queued changes unless aborted.
*/
export function coalesceRuns(run: () => Promise<unknown>, signal: AbortSignal): () => void {
let running = false;
let queued = false;

// Read through a call, not `while (queued)`: the flag is only ever set from the closure below
// while a run is awaited, which narrowing cannot see.
const takeQueued = (): boolean => {
const wasQueued = queued;
queued = false;
return wasQueued;
};

const drain = async (): Promise<void> => {
running = true;
try {
do {
// eslint-disable-next-line no-await-in-loop -- serializing is the point: one run at a time
await run().catch(noop);
} while (!signal.aborted && takeQueued());
} finally {
running = false;
}
};

return () => {
if (signal.aborted) return;
if (running) {
queued = true;
return;
}
void drain().catch(noop);
};
}
38 changes: 35 additions & 3 deletions packages/client/workbench/src/runtime/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { LinkCodeProvider } from '@linkcode/client-core';
import type { LinkCodeSdkClient } from '@linkcode/sdk';
import { listSessions, listWorkspaces } from '@linkcode/sdk';
import { ComposeContextProvider } from 'foxact/compose-context-provider';
import { nullthrow } from 'foxact/nullthrow';
import { useEffect } from 'foxact/use-abortable-effect';
Expand All @@ -10,6 +11,7 @@ import { wait } from 'foxts/wait';
import { createContext, useContext, useRef, useSyncExternalStore } from 'react';
import type { Cache, Middleware as SWRMiddleware } from 'swr';
import { SWRConfig, useSWRConfig } from 'swr';
import { coalesceRuns } from './coalesce';
import type {
WorkbenchConnectionGeneration,
WorkbenchConnectionSource,
Expand Down Expand Up @@ -168,14 +170,29 @@ function WorkbenchRuntimeGeneration({
<LinkCodeProvider key="linkcode" client={contextGeneration.client.raw} />,
]}
>
<ReadyRevalidator controller={controller} generation={contextGeneration}>
<HostRevalidator controller={controller} generation={contextGeneration}>
{children}
</ReadyRevalidator>
</HostRevalidator>
</ComposeContextProvider>
);
}

function ReadyRevalidator({
/** A `listSessions` / `listWorkspaces` cache entry, whichever surface owns it. Both tayori key
* forms land in the cache as the resolved `[sdkMethod, arg, cacheTags]` tuple, so this matches the
* tuple rather than tayori's brand — the lazy form brands its outer function, not the array. */
function isHostListKey(key: unknown): boolean {
return Array.isArray(key) && (key[0] === listSessions || key[0] === listWorkspaces);
}

/**
* Keeps SWR in step with the host: everything once a generation is protocol-ready, and the two
* list caches on each `session.changed` push. The daemon registers/freshens a session's workspace
* before it announces the record on start and resume, so one frame stands for both lists there;
* import announces first and touches after, so a brand-new imported cwd can need the next
* revalidation. Pushes are coalesced: a single start emits several frames, and a bulk import emits
* one per entry, while SWR's key-filter `mutate` deletes its own dedupe markers.
*/
function HostRevalidator({
children,
controller,
generation,
Expand All @@ -197,6 +214,21 @@ function ReadyRevalidator({
void mutate(trueFn);
}, [generation.id, mutate, status]);

const client = generation.client.raw;
useEffect(
(signal) =>
client.subscribeSessionChanged(
coalesceRuns(async () => {
// Recovery retains disposed generations in React until a replacement is ready.
const snapshot = controller.getSnapshot();
if (snapshot.status === 'ready' && snapshot.contextGeneration?.id === generation.id) {
await mutate(isHostListKey);
}
}, signal),
),
[client, controller, generation.id, mutate],
);

return children;
}

Expand Down
9 changes: 6 additions & 3 deletions packages/client/workbench/src/workspace/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { listWorkspaces } from '@linkcode/sdk';
import { useData } from '../runtime/tayori';

/**
* Every registered workspace (directory), most recently used first. No push invalidation yet:
* after a workspace mutation the caller must call this hook's `mutate()` — the same convention
* `useWorkbenchSessions` follows for session mutations.
* Every registered workspace (directory), most recently used first. The runtime revalidates it on
* every `session.changed` push, which covers a session another client starts or resumes: the daemon
* registers that workspace before announcing the record. It does not cover an import of a
* brand-new cwd (announced before the touch) or another client's explicit register/rename/archive,
* which have no push at all. A workspace mutation this client issues itself still calls `mutate()`,
* the same convention `useWorkbenchSessions` follows for session mutations.
*/
export function useWorkspaces() {
return useData(listWorkspaces, {});
Expand Down
Loading
Loading