Skip to content

Commit f695dd8

Browse files
committed
fix(workbench,ui): follow the host default after a submit, freeze only parked reads, and continue from the last completed turn
1 parent 18c254d commit f695dd8

15 files changed

Lines changed: 266 additions & 107 deletions

‎packages/client/core/AGENTS.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,10 @@ Rules the projection store enforces — keep them when touching it:
4444
- **A parked view is frozen at its read.** Browsing an earlier version reads toward that
4545
`leafTurnId` (`ConversationSeedSource.leafTurnId`) with `followLive: false`: the live stream
4646
belongs to the active lineage's run and must not fold into another version, and a graph change
47-
is the owner's business (the "continued elsewhere" chip), not a re-read. Only the view of a
48-
leaf that is itself in flight (the client's own edit or continue, before the daemon moves the
49-
default) follows live.
47+
is the owner's business (the "continued elsewhere" chip), not a re-read. The caller decides
48+
`followLive` from the read the store holds — a leaf on, behind, or ahead of the host default
49+
follows; another version is frozen — never from view state, which runs ahead of the read by a
50+
round trip on every switch.
5051
- **Edits and continues are explicit-parent submits** (`submitTurn(…, target)`): the daemon
5152
validates the parent and `expectedGraphRevision` and answers typed `conflict`/`busy`; the
5253
client never calls `history.branch` on a host that serves the graph and can fork.

‎packages/client/workbench/AGENTS.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,14 @@ app-specific entries (`apps/desktop`, `apps/webview`) and pure presentation (`pa
4040
remembered descent per parent) + the pure helpers in `lineage.ts` + `use-conversation-graph.ts`
4141
(the turn tree, revalidated on `conversation.graph.changed`). The seed hook keeps a stable SWR
4242
key and reads the parked leaf at fetch time — a version switch revalidates in place instead of
43-
flashing an empty timeline — and freezes the store (`followLive: false`) while parked; a parked
44-
view's sends, edits, and `/`/`$` inputs are explicit-parent `turn.submit`s, and the store
45-
releases the view once the host default runs through the parked leaf.
43+
flashing an empty timeline — and freezes the store (`followLive: false`) while the read it holds
44+
was made toward a parked version the active lineage does not run through (`onActiveLineage`); a
45+
read of the host default keeps following, and the next graph snapshot re-reads it once the tree
46+
has moved past it (an edit from any device). A parked view's sends and `/`/`$` inputs are
47+
explicit-parent `turn.submit`s under the version's last completed turn, an edit is a sibling
48+
under the edited turn's parent, and a successful submit follows the host default again (the
49+
daemon moved it before replying); the store also releases a parked view once the default runs
50+
through its leaf.
4651
- `terminal/` — the daemon-backed interactive terminal: the panel container, the key-scoped
4752
session registry that retains/detaches (rather than kills) a PTY across remounts, viewer
4853
attachment containers, and the transport-backed `TerminalSession`. Only the current controller

‎packages/client/workbench/src/surface/__tests__/lineage-store.test.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,23 @@ beforeEach(() => {
2727
});
2828

2929
describe('lineage store', () => {
30-
it('parks, dismisses the elsewhere chip for one revision, and follows again', () => {
30+
it('parks against the host default, dismisses the elsewhere chip per default, and follows again', () => {
3131
const store = useLineageStore.getState();
32-
store.park(sessionId, 'B1' as TurnId, 3);
32+
store.park(sessionId, 'B1' as TurnId, 'B2' as TurnId);
3333
expect(useLineageStore.getState().parkedBySession[sessionId]).toEqual({
3434
leafTurnId: 'B1',
35-
atRevision: 3,
36-
dismissedRevision: null,
35+
sinceLeafTurnId: 'B2',
36+
dismissedLeafTurnId: undefined,
3737
});
38-
store.dismissElsewhere(sessionId, 4);
39-
expect(useLineageStore.getState().parkedBySession[sessionId]?.dismissedRevision).toBe(4);
38+
store.dismissElsewhere(sessionId, 'C2' as TurnId);
39+
expect(useLineageStore.getState().parkedBySession[sessionId]?.dismissedLeafTurnId).toBe('C2');
4040
store.follow(sessionId);
4141
expect(useLineageStore.getState().parkedBySession[sessionId]).toBeUndefined();
4242
});
4343

4444
it('releases a parked view once the host default runs through its leaf', () => {
4545
const store = useLineageStore.getState();
46-
store.park(sessionId, 'B1' as TurnId, 3);
46+
store.park(sessionId, 'B1' as TurnId, 'B2' as TurnId);
4747
// The active lineage moved to a sibling: still parked.
4848
store.noteGraph(snapshot('B2', [turn('A', null), turn('B1', 'A'), turn('B2', 'A')]));
4949
expect(useLineageStore.getState().parkedBySession[sessionId]?.leafTurnId).toBe('B1');

‎packages/client/workbench/src/surface/__tests__/lineage.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import type { ConversationGraphSnapshot } from '@linkcode/client-core';
12
import type { ConversationGraphTurn, SessionId, TurnId } from '@linkcode/schema';
23
import { userRowMessageId } from '@linkcode/schema';
34
import { describe, expect, it } from 'vitest';
45
import {
6+
continuationParent,
57
descendToLeaf,
68
lineageIncludes,
79
lineageParentKey,
810
lineagePath,
911
lineageVersions,
12+
onActiveLineage,
1013
siblingsOf,
14+
timelineLeftActiveLineage,
1115
turnsById,
1216
} from '../lineage';
1317

@@ -88,4 +92,49 @@ describe('lineage helpers', () => {
8892
// A remembered child that no longer exists falls back to the newest.
8993
expect(descendToLeaf(TURNS, 'B2' as TurnId, { B2: 'gone' as TurnId })).toBe('C3');
9094
});
95+
96+
it('continues from a version’s last completed turn, never from a failed tip', () => {
97+
const byId = turnsById(TURNS);
98+
expect(continuationParent(byId, 'C3' as TurnId)).toBe('B2');
99+
expect(continuationParent(byId, 'C1' as TurnId)).toBe('C1');
100+
const failedRoot = turnsById([turn('F', null, 1, 1, 'failed')]);
101+
expect(continuationParent(failedRoot, 'F' as TurnId)).toBeNull();
102+
});
103+
104+
it('places a read on the active lineage unless it is of another version', () => {
105+
const graph = (activeLeaf: string): ConversationGraphSnapshot => ({
106+
sessionId,
107+
graphRevision: 1,
108+
activeLeafTurnId: activeLeaf as TurnId,
109+
turns: TURNS,
110+
});
111+
expect(onActiveLineage('C2' as TurnId, graph('C2'))).toBe(true);
112+
// Behind the default on its own lineage: a continuation folds onto it.
113+
expect(onActiveLineage('B2' as TurnId, graph('C2'))).toBe(true);
114+
// Another version.
115+
expect(onActiveLineage('C1' as TurnId, graph('C2'))).toBe(false);
116+
// Ahead of a stale snapshot, or not placed by it yet.
117+
expect(onActiveLineage('C2' as TurnId, graph('B2'))).toBe(true);
118+
expect(onActiveLineage('new' as TurnId, graph('C2'))).toBe(true);
119+
// Nothing to judge against without a leaf or a tree.
120+
expect(onActiveLineage(undefined, graph('C2'))).toBe(true);
121+
expect(onActiveLineage('C1' as TurnId, undefined)).toBe(true);
122+
});
123+
124+
it('notices a timeline showing a turn off the active lineage', () => {
125+
const graph = (activeLeaf: string): ConversationGraphSnapshot => ({
126+
sessionId,
127+
graphRevision: 1,
128+
activeLeafTurnId: activeLeaf as TurnId,
129+
turns: TURNS,
130+
});
131+
const rows = (...ids: string[]) => ids.map((id) => userRowMessageId(id as TurnId));
132+
// Following B1's lineage when the default moved to B2's: B1 and C1 are off it.
133+
expect(timelineLeftActiveLineage(rows('A', 'B1', 'C1'), graph('C2'))).toBe(true);
134+
// The edit's own echo folded onto the old lineage, then the tree caught up.
135+
expect(timelineLeftActiveLineage(rows('A', 'B1', 'B2'), graph('B2'))).toBe(true);
136+
expect(timelineLeftActiveLineage(rows('A', 'B2', 'C2'), graph('C2'))).toBe(false);
137+
// A row the tree does not know yet is a fresher echo, not a foreign version.
138+
expect(timelineLeftActiveLineage(rows('A', 'B2', 'C2', 'new'), graph('C2'))).toBe(false);
139+
});
91140
});

‎packages/client/workbench/src/surface/lineage-store.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,23 @@ import { lineageIncludes, turnsById } from './lineage';
77
export interface ParkedLineage {
88
/** The leaf the timeline reads toward. */
99
leafTurnId: TurnId;
10-
/** The graph revision when the viewer parked; a later one means the conversation moved on
11-
* elsewhere while this viewer stayed. */
12-
atRevision: number;
13-
/** The revision whose "continued elsewhere" chip the viewer dismissed. */
14-
dismissedRevision: number | null;
10+
/** The host default when the viewer parked; a different one means the conversation moved on
11+
* elsewhere while this viewer stayed — a failed attempt or a settle, which only bump the
12+
* revision, is nobody's news. */
13+
sinceLeafTurnId: TurnId | undefined;
14+
/** The host default whose "continued elsewhere" chip the viewer dismissed. */
15+
dismissedLeafTurnId: TurnId | undefined;
1516
}
1617

1718
interface LineageState {
1819
parkedBySession: Record<string, ParkedLineage>;
1920
/** Remembered descent per parent (`lineageParentKey`): `‹ ›` returns to the version last viewed. */
2021
preferredChildBySession: Record<string, Record<string, TurnId>>;
21-
park: (sessionId: SessionId, leafTurnId: TurnId, atRevision: number) => void;
22+
park: (sessionId: SessionId, leafTurnId: TurnId, activeLeafTurnId: TurnId | undefined) => void;
2223
/** Back to the host default: the active lineage, following it live. */
2324
follow: (sessionId: SessionId) => void;
2425
rememberChild: (sessionId: SessionId, parentKey: string, childTurnId: TurnId) => void;
25-
dismissElsewhere: (sessionId: SessionId, revision: number) => void;
26+
dismissElsewhere: (sessionId: SessionId, activeLeafTurnId: TurnId | undefined) => void;
2627
/** A fresh graph snapshot: once the host default runs through the parked leaf — the viewer's
2728
* own edit or continue landed, or a plain send extended the version it was on — the view is at
2829
* that lineage's tip again and follows. */
@@ -43,11 +44,15 @@ function without<T>(record: Record<string, T>, key: string): Record<string, T> {
4344
export const useLineageStore = create<LineageState>()((set) => ({
4445
parkedBySession: {},
4546
preferredChildBySession: {},
46-
park: (sessionId, leafTurnId, atRevision) =>
47+
park: (sessionId, leafTurnId, activeLeafTurnId) =>
4748
set((state) => ({
4849
parkedBySession: {
4950
...state.parkedBySession,
50-
[sessionId]: { leafTurnId, atRevision, dismissedRevision: null },
51+
[sessionId]: {
52+
leafTurnId,
53+
sinceLeafTurnId: activeLeafTurnId,
54+
dismissedLeafTurnId: undefined,
55+
},
5156
},
5257
})),
5358
follow: (sessionId) =>
@@ -63,14 +68,14 @@ export const useLineageStore = create<LineageState>()((set) => ({
6368
[sessionId]: { ...state.preferredChildBySession[sessionId], [parentKey]: childTurnId },
6469
},
6570
})),
66-
dismissElsewhere: (sessionId, revision) =>
71+
dismissElsewhere: (sessionId, activeLeafTurnId) =>
6772
set((state) => {
6873
const parked = state.parkedBySession[sessionId];
6974
if (parked === undefined) return state;
7075
return {
7176
parkedBySession: {
7277
...state.parkedBySession,
73-
[sessionId]: { ...parked, dismissedRevision: revision },
78+
[sessionId]: { ...parked, dismissedLeafTurnId: activeLeafTurnId },
7479
},
7580
};
7681
}),

‎packages/client/workbench/src/surface/lineage.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ConversationGraphSnapshot } from '@linkcode/client-core';
12
import type { ConversationGraphTurn, TurnId } from '@linkcode/schema';
23
import { userRowMessageId } from '@linkcode/schema';
34
import type { TurnVersion } from '@linkcode/ui';
@@ -40,6 +41,57 @@ export function lineageIncludes(
4041
return lineagePath(byId, leafTurnId).some((turn) => turn.turnId === turnId);
4142
}
4243

44+
/** Where a send from a view of `leafTurnId` lands: the lineage's last completed turn — a failed or
45+
* cancelled tip ran nothing to continue from, so the send is a sibling of it — else a new root. */
46+
export function continuationParent(
47+
byId: ReadonlyMap<TurnId, ConversationGraphTurn>,
48+
leafTurnId: TurnId,
49+
): TurnId | null {
50+
const path = lineagePath(byId, leafTurnId);
51+
for (let i = path.length - 1; i >= 0; i--) {
52+
if (path[i].state === 'completed') return path[i].turnId;
53+
}
54+
return null;
55+
}
56+
57+
/** Whether a read toward `leafTurnId` is on the active lineage as far as `graph` knows: on or
58+
* behind the host default, ahead of a stale snapshot, or not placed by it yet. Only a read of
59+
* another version is not — the live stream, which belongs to the active lineage's run, must not
60+
* fold into it. */
61+
export function onActiveLineage(
62+
leafTurnId: TurnId | undefined,
63+
graph: ConversationGraphSnapshot | undefined,
64+
): boolean {
65+
if (leafTurnId === undefined || graph?.activeLeafTurnId === undefined) return true;
66+
const byId = turnsById(graph.turns);
67+
if (!byId.has(leafTurnId)) return true;
68+
return (
69+
lineageIncludes(byId, graph.activeLeafTurnId, leafTurnId) ||
70+
lineageIncludes(byId, leafTurnId, graph.activeLeafTurnId)
71+
);
72+
}
73+
74+
/** Whether a timeline shows a user row of a turn the active lineage does not run through: the host
75+
* default moved to another version while this view followed it (an edit from any device), so the
76+
* view must read toward the default. Rows the tree does not know yet — an echo fresher than the
77+
* snapshot — do not count. */
78+
export function timelineLeftActiveLineage(
79+
userRowIds: readonly string[],
80+
graph: ConversationGraphSnapshot,
81+
): boolean {
82+
const known = new Set<string>(graph.turns.map((turn) => userRowMessageId(turn.turnId)));
83+
const onPath = new Set<string>(
84+
lineagePath(turnsById(graph.turns), graph.activeLeafTurnId).map((turn) =>
85+
userRowMessageId(turn.turnId),
86+
),
87+
);
88+
for (let i = 0, len = userRowIds.length; i < len; i++) {
89+
const id = userRowIds[i];
90+
if (known.has(id) && !onPath.has(id)) return true;
91+
}
92+
return false;
93+
}
94+
4395
/** A turn's siblings in ordinal order, itself included. */
4496
export function siblingsOf(
4597
turns: readonly ConversationGraphTurn[],

‎packages/client/workbench/src/surface/use-conversation-graph.ts‎

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { noop } from 'foxact/noop';
66
import { useEffect } from 'react';
77
import { useWorkbenchSdkClient } from '../runtime/provider';
88
import { useData } from '../runtime/tayori';
9-
import { useLineageStore } from './lineage-store';
109

1110
async function fetchConversationGraph(
1211
options: Options<{ sessionId: SessionId }>,
@@ -16,9 +15,10 @@ async function fetchConversationGraph(
1615

1716
/**
1817
* The session's turn tree — ids, parents, ordinals, states — revalidated on every
19-
* `conversation.graph.changed`. Undefined before the first read and on hosts without a graph.
20-
* `onSnapshot` runs per fresh snapshot for event-time bookkeeping (a parked view that the host
21-
* default caught up with), never during render.
18+
* `conversation.graph.changed` (a settle re-announces at the same revision, so the badges follow).
19+
* Undefined before the first read and on hosts without a graph. `onSnapshot` runs per fresh
20+
* snapshot for event-time bookkeeping (a parked view that the host default caught up with), never
21+
* during render.
2222
*/
2323
export function useConversationGraph(
2424
sessionId: SessionId | null,
@@ -31,19 +31,9 @@ export function useConversationGraph(
3131
});
3232
useEffect(() => {
3333
if (!enabled) return;
34-
const revalidate = (): void => {
34+
return client.subscribeGraphChanges(sessionId, () => {
3535
void mutate().catch(noop);
36-
};
37-
const unsubscribeChanges = client.subscribeGraphChanges(sessionId, revalidate);
38-
// Parking at a just-submitted turn must see it in the tree before any `graph.changed` lands:
39-
// its state decides whether the view follows the live stream.
40-
const unsubscribeParked = useLineageStore.subscribe((state, previous) => {
41-
if (state.parkedBySession[sessionId] !== previous.parkedBySession[sessionId]) revalidate();
4236
});
43-
return () => {
44-
unsubscribeChanges();
45-
unsubscribeParked();
46-
};
4737
}, [client, enabled, sessionId, mutate]);
4838
return data;
4939
}

0 commit comments

Comments
 (0)