Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 6 additions & 2 deletions packages/client/workbench/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@ 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 (the daemon registers/freshens a session's workspace as part of
start/resume/import, so that one frame covers both lists; an explicit `workspace.register` /
rename / archive from *another* client has no push and waits for the next focus revalidation);
Comment thread
Zerlight marked this conversation as resolved.
Outdated
it does not own connection state.
- `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
12 changes: 10 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,7 @@ export class DevMockHost {
updatedAt: now,
origin,
});
this.send({ kind: 'session.changed', sessionId: session.sessionId, reason: 'created' });
Comment thread
Zerlight marked this conversation as resolved.
this.send({
kind: 'session.imported',
replyTo,
Expand Down Expand Up @@ -1084,6 +1087,8 @@ export class DevMockHost {
return;
}
session.status = 'idle';
// 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 +1244,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
27 changes: 24 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 { isInternalSWRKey } from 'tayori';
import type {
WorkbenchConnectionGeneration,
WorkbenchConnectionSource,
Expand Down Expand Up @@ -168,14 +170,24 @@ 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({
/** Every `useData(listSessions)` / `useData(listWorkspaces)` cache entry, whichever surface owns it. */
function isHostListKey(key: unknown): boolean {
return isInternalSWRKey(key) && (key[0] === listSessions || key[0] === listWorkspaces);
Comment thread
Zerlight marked this conversation as resolved.
Outdated
}

/**
* 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
* as part of start/resume/import, so that one frame stands for both lists.
*/
Comment thread
Zerlight marked this conversation as resolved.
function HostRevalidator({
children,
controller,
generation,
Expand All @@ -197,6 +209,15 @@ function ReadyRevalidator({
void mutate(trueFn);
}, [generation.id, mutate, status]);

const client = generation.client.raw;
useEffect(
() =>
client.subscribeSessionChanged(() => {
void mutate(isHostListKey);
Comment thread
Zerlight marked this conversation as resolved.
Outdated
}),
[client, mutate],
);

return children;
}

Expand Down
7 changes: 4 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,10 @@ 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 (the daemon registers/freshens a session's workspace as part of
* start/resume/import); a workspace mutation this client issues itself still calls `mutate()` —
* the same convention `useWorkbenchSessions` follows for session mutations.
Comment thread
Zerlight marked this conversation as resolved.
*/
export function useWorkspaces() {
return useData(listWorkspaces, {});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import { useLinkCodeClient } from '@linkcode/client-core';
import { listSessions } from '@linkcode/sdk';
import { cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, expect, it } from 'vitest';
import { createDevMockTransport } from '../../src/mock/dev-mock-transport';
import { DebugProvider } from '../../src/runtime/debug';
import { WorkbenchRuntimeProvider } from '../../src/runtime/provider';
import { useData } from '../../src/runtime/tayori';
import { useWorkspaces } from '../../src/workspace/hooks';

const connectionSource = {
resolve: () => ({ endpoint: 'mock://session-changed', transport: createDevMockTransport() }),
};
Comment thread
Zerlight marked this conversation as resolved.

function Runtime({ children }: React.PropsWithChildren): React.ReactNode {
return (
<DebugProvider>
<WorkbenchRuntimeProvider connectionSource={connectionSource}>
{children}
</WorkbenchRuntimeProvider>
</DebugProvider>
);
}

/** The sidebar's two inputs, read the way the workbench reads them — through the shared hooks,
* which never call `mutate()` themselves. */
function useSidebarInputs() {
const { data: workspaces } = useWorkspaces();
const { data: sessions } = useData(listSessions, {});
return { client: useLinkCodeClient(), workspaces, sessions };
}

afterEach(cleanup);

// The mock host answers every control request after a scripted latency; each step here is one or
// more of those round trips.
const STEP_TIMEOUT = { timeout: 4000 };

it('lists the workspace another client created by starting a session in it', async () => {
const { result } = renderHook(useSidebarInputs, { wrapper: Runtime });
await waitFor(() => expect(result.current.workspaces).toBeDefined(), STEP_TIMEOUT);
const cwd = '/mock/elsewhere/new-repo';
expect(result.current.workspaces?.map((workspace) => workspace.cwd)).not.toContain(cwd);

// Bypassing the workbench's own create path stands in for another client: this client only
// learns about the session from the host's pushed frames.
const sessionId = await result.current.client.startSession({ kind: 'claude-code', cwd });

await waitFor(() => {
expect(result.current.workspaces?.map((workspace) => workspace.cwd)).toContain(cwd);
expect(result.current.sessions?.map((session) => session.sessionId)).toContain(sessionId);
}, STEP_TIMEOUT);
}, 15000);
Loading