Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
12 changes: 9 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,12 +310,14 @@ type ChatViewProps =
| {
environmentId: EnvironmentId;
threadId: ThreadId;
onDiffPanelOpen?: () => void;
routeKind: "server";
draftId?: never;
}
| {
environmentId: EnvironmentId;
threadId: ThreadId;
onDiffPanelOpen?: () => void;
routeKind: "draft";
draftId: DraftId;
};
Expand Down Expand Up @@ -569,7 +571,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
});

export default function ChatView(props: ChatViewProps) {
const { environmentId, threadId, routeKind } = props;
const { environmentId, threadId, routeKind, onDiffPanelOpen } = props;
const draftId = routeKind === "draft" ? props.draftId : null;
const routeThreadRef = useMemo(
() => scopeThreadRef(environmentId, threadId),
Expand Down Expand Up @@ -1470,6 +1472,9 @@ export default function ChatView(props: ChatViewProps) {
if (!isServerThread) {
return;
}
if (!diffOpen) {
onDiffPanelOpen?.();
}
void navigate({
to: "/$environmentId/$threadId",
params: {
Expand All @@ -1482,7 +1487,7 @@ export default function ChatView(props: ChatViewProps) {
return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
},
});
}, [diffOpen, environmentId, isServerThread, navigate, threadId]);
}, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]);

const envLocked = Boolean(
activeThread &&
Expand Down Expand Up @@ -3245,6 +3250,7 @@ export default function ChatView(props: ChatViewProps) {
if (!isServerThread) {
return;
}
onDiffPanelOpen?.();
void navigate({
to: "/$environmentId/$threadId",
params: {
Expand All @@ -3259,7 +3265,7 @@ export default function ChatView(props: ChatViewProps) {
},
});
},
[environmentId, isServerThread, navigate, threadId],
[environmentId, isServerThread, navigate, onDiffPanelOpen, threadId],
);
const onRevertUserMessage = useCallback(
(messageId: MessageId) => {
Expand Down
36 changes: 26 additions & 10 deletions apps/web/src/routes/_chat.$environmentId.$threadId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
stripDiffSearchParams,
} from "../diffRouteSearch";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { selectEnvironmentState, selectThreadByRef, useStore } from "../store";
import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";
import { createThreadSelectorByRef } from "../storeSelectors";
import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";
import { Sheet, SheetPopup } from "../components/ui/sheet";
Expand Down Expand Up @@ -172,7 +172,7 @@ function ChatThreadRouteView() {
(store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete,
);
const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
const threadExists = useStore((store) => selectThreadByRef(store, threadRef) !== undefined);
const threadExists = useStore((store) => selectThreadExistsByRef(store, threadRef));
const environmentHasServerThreads = useStore(
(store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0,
);
Expand All @@ -193,7 +193,26 @@ function ChatThreadRouteView() {
const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
const diffOpen = search.diff === "1";
const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen);
const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null;
const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({
threadKey: currentThreadKey,
hasOpenedDiff: diffOpen,
}));
const hasOpenedDiff =
diffPanelMountState.threadKey === currentThreadKey
? diffPanelMountState.hasOpenedDiff
: diffOpen;
const markDiffOpened = useCallback(() => {
setDiffPanelMountState((previous) => {
if (previous.threadKey === currentThreadKey && previous.hasOpenedDiff) {
return previous;
}
return {
threadKey: currentThreadKey,
hasOpenedDiff: true,
};
});
}, [currentThreadKey]);
const closeDiff = useCallback(() => {
if (!threadRef) {
return;
Expand All @@ -208,6 +227,7 @@ function ChatThreadRouteView() {
if (!threadRef) {
return;
}
markDiffOpened();
void navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(threadRef),
Expand All @@ -216,13 +236,7 @@ function ChatThreadRouteView() {
return { ...rest, diff: "1" };
},
});
}, [navigate, threadRef]);

useEffect(() => {
if (diffOpen) {
setHasOpenedDiff(true);
}
}, [diffOpen]);
}, [markDiffOpened, navigate, threadRef]);

useEffect(() => {
if (!threadRef || !bootstrapComplete) {
Expand Down Expand Up @@ -254,6 +268,7 @@ function ChatThreadRouteView() {
<ChatView
environmentId={threadRef.environmentId}
threadId={threadRef.threadId}
onDiffPanelOpen={markDiffOpened}
routeKind="server"
/>
</SidebarInset>
Expand All @@ -273,6 +288,7 @@ function ChatThreadRouteView() {
<ChatView
environmentId={threadRef.environmentId}
threadId={threadRef.threadId}
onDiffPanelOpen={markDiffOpened}
routeKind="server"
/>
</SidebarInset>
Expand Down
124 changes: 124 additions & 0 deletions apps/web/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
applyOrchestrationEvents,
selectEnvironmentState,
selectProjectsAcrossEnvironments,
selectThreadByRef,
selectThreadExistsByRef,
setThreadBranch,
selectThreadsAcrossEnvironments,
syncServerReadModel,
Expand Down Expand Up @@ -245,6 +247,128 @@ function makeEvent<T extends OrchestrationEvent["type"]>(
} as Extract<OrchestrationEvent, { type: T }>;
}

describe("thread selection memoization", () => {
it("returns stable thread references for repeated reads of the same state", () => {
const thread = makeThread({
messages: [
{
id: MessageId.make("message-1"),
role: "user",
text: "hello",
createdAt: "2026-02-13T00:01:00.000Z",
streaming: false,
},
],
activities: [
{
id: EventId.make("activity-1"),
tone: "info",
kind: "step",
summary: "working",
payload: {},
turnId: TurnId.make("turn-1"),
createdAt: "2026-02-13T00:01:30.000Z",
},
],
proposedPlans: [
{
id: "plan-1",
turnId: null,
planMarkdown: "plan",
implementedAt: null,
implementationThreadId: null,
createdAt: "2026-02-13T00:02:00.000Z",
updatedAt: "2026-02-13T00:02:00.000Z",
},
],
turnDiffSummaries: [
{
turnId: TurnId.make("turn-1"),
completedAt: "2026-02-13T00:03:00.000Z",
files: [],
},
],
});
const state = makeState(thread);
const ref = scopeThreadRef(thread.environmentId, thread.id);

const first = selectThreadByRef(state, ref);
const second = selectThreadByRef(state, ref);

expect(first).toBeDefined();
expect(second).toBe(first);
expect(second?.messages).toBe(first?.messages);
expect(second?.activities).toBe(first?.activities);
expect(second?.proposedPlans).toBe(first?.proposedPlans);
expect(second?.turnDiffSummaries).toBe(first?.turnDiffSummaries);
});

it("reuses the derived thread when the app state wrapper changes but thread data does not", () => {
const thread = makeThread({
messages: [
{
id: MessageId.make("message-1"),
role: "assistant",
text: "done",
createdAt: "2026-02-13T00:01:00.000Z",
streaming: false,
},
],
});
const state = makeState(thread);
const ref = scopeThreadRef(thread.environmentId, thread.id);
const wrappedState: AppState = {
...state,
environmentStateById: { ...state.environmentStateById },
};

const first = selectThreadByRef(state, ref);
const second = selectThreadByRef(wrappedState, ref);

expect(second).toBe(first);
});

it("updates the derived thread when the underlying thread data changes", () => {
const thread = makeThread();
const ref = scopeThreadRef(thread.environmentId, thread.id);
const firstState = makeState(thread);
const secondState = makeState({
...thread,
messages: [
{
id: MessageId.make("message-2"),
role: "user",
text: "new",
createdAt: "2026-02-13T00:04:00.000Z",
streaming: false,
},
],
});

const first = selectThreadByRef(firstState, ref);
const second = selectThreadByRef(secondState, ref);

expect(second).not.toBe(first);
expect(second?.messages).toHaveLength(1);
expect(second?.messages[0]?.text).toBe("new");
});

it("checks thread existence without materializing the full thread", () => {
const thread = makeThread();
const state = makeState(thread);
const ref = scopeThreadRef(thread.environmentId, thread.id);

expect(selectThreadExistsByRef(state, ref)).toBe(true);
expect(
selectThreadExistsByRef(
state,
scopeThreadRef(thread.environmentId, ThreadId.make("missing")),
),
).toBe(false);
expect(selectThreadExistsByRef(state, null)).toBe(false);
});
});

function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) {
return {
id: ThreadId.make("thread-1"),
Expand Down
Loading
Loading