Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
45e5724
Optimize orchestration sync and sidebar thread snapshots
juliusmarminge Mar 29, 2026
bef63d4
Merge origin/main into t3code/ui-perf-regression
juliusmarminge Mar 29, 2026
a7dc0c9
Preserve state on no-op deletes and stale turn diffs
juliusmarminge Mar 30, 2026
f6feb65
Refactor orchestration event batch cleanup
juliusmarminge Mar 30, 2026
8ec638c
Separate promoted draft cleanup from deleted thread cleanup
juliusmarminge Mar 30, 2026
708762e
Gate thread redirect on bootstrap completion
juliusmarminge Mar 30, 2026
1a4d757
Move sidebar visit state into a UI store
juliusmarminge Mar 30, 2026
9ca7319
Reduce thread hook re-renders in chat shortcuts
juliusmarminge Mar 30, 2026
bbb5742
Drive draft promotion through domain events
juliusmarminge Mar 30, 2026
eb180fa
Track local dispatch until server acknowledges
juliusmarminge Mar 30, 2026
d0823bf
Extract orchestration recovery coordinator for event replay
juliusmarminge Mar 30, 2026
a67bb27
Bound thread plan catalog cache memory
juliusmarminge Mar 30, 2026
314edce
Wait for server thread startup before opening plan sidebar
juliusmarminge Mar 30, 2026
0bd63bf
Refine sidebar ordering and thread lookups
juliusmarminge Mar 30, 2026
3bbafdb
Deduplicate repeated preferred sidebar items
juliusmarminge Mar 30, 2026
22a0b5d
Use shared contract types in web code
juliusmarminge Mar 30, 2026
9084cf5
Move thread plan catalog cache helper below constants
juliusmarminge Mar 30, 2026
0e1dfe5
Reduce chat thread catalog churn and guard replay recovery
juliusmarminge Mar 30, 2026
ea6a5ae
fix: add latestTurn regression guard to thread.message-sent handler
cursoragent Mar 30, 2026
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
103 changes: 88 additions & 15 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
import "../index.css";

import {
EventId,
ORCHESTRATION_WS_METHODS,
ORCHESTRATION_WS_CHANNELS,
type MessageId,
type OrchestrationEvent,
type OrchestrationReadModel,
type ProjectId,
type ServerConfig,
Expand Down Expand Up @@ -57,6 +60,8 @@ interface TestFixture {
let fixture: TestFixture;
const wsRequests: WsRequestEnvelope["body"][] = [];
let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null;
let wsClient: { send: (message: string) => void } | null = null;
let pushSequence = 1;
const wsLink = ws.link(/ws(s)?:\/\/.*/);

interface ViewportSpec {
Expand Down Expand Up @@ -336,6 +341,79 @@ function addThreadToSnapshot(
};
}

function createThreadCreatedEvent(threadId: ThreadId, sequence: number): OrchestrationEvent {
return {
sequence,
eventId: EventId.makeUnsafe(`event-thread-created-${sequence}`),
aggregateKind: "thread",
aggregateId: threadId,
occurredAt: NOW_ISO,
commandId: null,
causationEventId: null,
correlationId: null,
metadata: {},
type: "thread.created",
payload: {
threadId,
projectId: PROJECT_ID,
title: "New thread",
modelSelection: {
provider: "codex",
model: "gpt-5",
},
runtimeMode: "full-access",
interactionMode: "default",
branch: "main",
worktreePath: null,
createdAt: NOW_ISO,
updatedAt: NOW_ISO,
},
};
}

function sendOrchestrationDomainEvent(event: OrchestrationEvent): void {
if (!wsClient) {
throw new Error("WebSocket client not connected");
}
wsClient.send(
JSON.stringify({
type: "push",
sequence: pushSequence++,
channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
data: event,
}),
);
}

async function waitForWsClient(): Promise<{ send: (message: string) => void }> {
let client: { send: (message: string) => void } | null = null;
await vi.waitFor(
() => {
client = wsClient;
expect(client).toBeTruthy();
},
{ timeout: 8_000, interval: 16 },
);
if (!client) {
throw new Error("WebSocket client not connected");
}
return client;
}

async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
await waitForWsClient();
fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId);
sendOrchestrationDomainEvent(
createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence),
);
await vi.waitFor(
() => {
expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined();
},
{ timeout: 8_000, interval: 16 },
);
}

function createDraftOnlySnapshot(): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-draft-target" as MessageId,
Expand Down Expand Up @@ -500,10 +578,12 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {

const worker = setupWorker(
wsLink.addEventListener("connection", ({ client }) => {
wsClient = client;
pushSequence = 1;
client.send(
JSON.stringify({
type: "push",
sequence: 1,
sequence: pushSequence++,
channel: WS_CHANNELS.serverWelcome,
data: fixture.welcome,
}),
Expand Down Expand Up @@ -875,7 +955,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
useStore.setState({
projects: [],
threads: [],
threadsHydrated: false,
bootstrapComplete: false,
});
});

Expand Down Expand Up @@ -1800,21 +1880,16 @@ describe("ChatView timeline estimator parity (full app)", () => {
// The composer editor should be present for the new draft thread.
await waitForComposerEditor();

// Simulate the snapshot sync arriving from the server after the draft
// thread has been promoted to a server thread (thread.create + turn.start
// succeeded). The snapshot now includes the new thread, and the sync
// should clear the draft without disrupting the route.
const { syncServerReadModel } = useStore.getState();
syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId));

// Clear the draft now that the server thread exists (mirrors EventRouter behavior).
useComposerDraftStore.getState().clearDraftThread(newThreadId);
// Simulate the steady-state promotion path: the server emits
// `thread.created`, the client materializes the thread incrementally,
// and the draft is cleared by live batch effects.
await promoteDraftThreadViaDomainEvent(newThreadId);

// The route should still be on the new thread — not redirected away.
await waitForURL(
mounted.router,
(path) => path === newThreadPath,
"New thread should remain selected after snapshot sync clears the draft.",
"New thread should remain selected after server thread promotion clears the draft.",
);

// The empty thread view and composer should still be visible.
Expand Down Expand Up @@ -2136,9 +2211,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
);
const promotedThreadId = promotedThreadPath.slice(1) as ThreadId;

const { syncServerReadModel } = useStore.getState();
syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId));
useComposerDraftStore.getState().clearDraftThread(promotedThreadId);
await promoteDraftThreadViaDomainEvent(promotedThreadId);

const freshThreadPath = await triggerChatNewShortcutUntilPath(
mounted.router,
Expand Down
Loading
Loading