Skip to content

Commit a14e454

Browse files
committed
fix(sessions): follow a handoff into the new session
A successful /handoff created the runtime's fresh session and the sidebar indexed it, but the app stayed on the old route and the builtin claimed it had "moved you". Confirming the gate now marks the pending handoff, and the first session_upserted for a different session in that project navigates to it - unless the viewer deliberately moved to another session meanwhile, or two minutes have passed. Resolves #6.
1 parent 816b03f commit a14e454

4 files changed

Lines changed: 60 additions & 3 deletions

File tree

src/components/chat/hooks/useChatComposerState.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
22
import type { ChangeEvent, ClipboardEvent, Dispatch, FormEvent, KeyboardEvent, MouseEvent, MutableRefObject, RefObject, SetStateAction, TouchEvent } from 'react';
33
import { useDropzone } from 'react-dropzone';
44

5+
import { useAppShellStore } from '../../../stores/useAppShellStore';
56
import { usePaletteOps } from '../../../stores/usePaletteOpsStore';
67
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
78
import type { CodeEditorDiffInfo, ChatMessage, PendingPermissionRequest, PermissionDecision, SessionEstablishedContext } from '../types/types';
@@ -149,7 +150,12 @@ export function useChatComposerState(args: UseChatComposerStateArgs) {
149150
const editQueuedDraft = useCallback((index: number) => setQueuedDrafts((q) => { const item = q[index]; if (!item) return q; setInput(item.content); inputRef.current = item.content; setAttachedImages(item.images); textareaRef.current?.focus(); return q.filter((_, position) => position !== index); }), []);
150151
const deleteQueuedDraft = useCallback((index: number) => setQueuedDrafts((q) => q.filter((_, position) => position !== index)), []);
151152
const moveQueuedDraft = useCallback((from: number, to: number) => setQueuedDrafts((q) => reorderQueue(q, from, to)), []);
152-
const confirmCommandGate = useCallback(() => { const gate = gateRef.current; if (!gate) return; announceGate(null); bypassGate.current = true; setInput(gate.text); inputRef.current = gate.text; void handleSubmit(syntheticSubmit()); }, [announceGate, handleSubmit]);
153+
const confirmCommandGate = useCallback(() => { const gate = gateRef.current; if (!gate) return; announceGate(null); bypassGate.current = true;
154+
// A confirmed handoff moves the runtime to a fresh session; the next
155+
// session_upserted for a new id in this project is it, and the app should
156+
// follow instead of staying on the old session (issue #6).
157+
if (/^\/handoff\b/.test(gate.text.trim())) useAppShellStore.getState().setPendingHandoff({ fromSessionId: conversation, projectId, at: Date.now() });
158+
setInput(gate.text); inputRef.current = gate.text; void handleSubmit(syntheticSubmit()); }, [announceGate, conversation, handleSubmit, projectId]);
153159
const cancelCommandGate = useCallback(() => { announceGate(null); bypassGate.current = false; }, [announceGate]);
154160
const handleClearInput = useCallback(() => { clearComposer(); textareaRef.current?.focus(); }, [clearComposer]);
155161
// The Changes tab's line comments arrive here: one new paragraph with the

src/hooks/useProjectsState.query.dom.bun.test.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { createElement } from 'react';
77

88
import type { ServerEvent } from '../contexts/WebSocketContext';
99
import type { Project } from '../types/app';
10-
import { resetAppShellStore } from '../stores/useAppShellStore';
10+
import { resetAppShellStore, useAppShellStore } from '../stores/useAppShellStore';
1111

1212
import { useProjectsState } from './useProjectsState';
1313

@@ -24,6 +24,8 @@ const project = (sessions: Project['sessions'] = []): Project => ({
2424

2525
type HookState = ReturnType<typeof useProjectsState>;
2626

27+
const navigations: string[] = [];
28+
2729
type Harness = {
2830
getState: () => HookState;
2931
emit: (event: ServerEvent) => void;
@@ -41,7 +43,7 @@ const renderHarness = (): Harness => {
4143
const HarnessComponent = () => {
4244
const hookState = useProjectsState({
4345
sessionId: null,
44-
navigate: (() => undefined) as never,
46+
navigate: ((path: string) => { navigations.push(path); }) as never,
4547
subscribe: (nextListener) => {
4648
listener = nextListener;
4749
return () => {
@@ -231,6 +233,35 @@ test('deleting a session removes its row and the row stays removed', async () =>
231233
}
232234
});
233235

236+
test('a confirmed handoff follows the new session when it is indexed; other upserts do not navigate', async () => {
237+
const sessions = [{ id: 'session-old', summary: 'Before the handoff', __provider: 'gjc' as const }];
238+
const fetch = installFetch([{ body: [project(sessions)] }]);
239+
try {
240+
const harness = renderHarness();
241+
await waitFor(() => assert.deepEqual(harness.getState().projects[0]?.sessions?.length, 1));
242+
243+
// An upsert for the old session while pending: not the handoff's.
244+
useAppShellStore.getState().setPendingHandoff({ fromSessionId: 'session-old', projectId: 'project-1', at: Date.now() });
245+
act(() => harness.emit({
246+
kind: 'session_upserted', sessionId: 'session-old', provider: 'gjc',
247+
session: { summary: 'Before the handoff', updatedAt: '2026-01-01T00:01:00Z' },
248+
project: { projectId: 'project-1' },
249+
} as never));
250+
assert.deepEqual(navigations, []);
251+
252+
// The handoff's new session arrives: follow it, once.
253+
act(() => harness.emit({
254+
kind: 'session_upserted', sessionId: 'session-new', provider: 'gjc',
255+
session: { summary: 'Untitled gjc Session', updatedAt: '2026-01-01T00:02:00Z' },
256+
project: { projectId: 'project-1' },
257+
} as never));
258+
assert.deepEqual(navigations, ['/session/session-new']);
259+
assert.equal(useAppShellStore.getState().pendingHandoff, null, 'the flag is consumed');
260+
} finally {
261+
fetch.restore();
262+
}
263+
});
264+
234265
test('optimistic sessions survive a shorter refetch page', async () => {
235266
const fetch = installFetch([{ body: [project()] }, { body: [project()] }]);
236267
try {

src/hooks/useProjectsState.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,19 @@ export function useProjectsState({ sessionId, navigate, subscribe, isMobile, act
205205
return next;
206206
});
207207
if (alias && sessionId === alias) navigate(`/session/${update.sessionId}`);
208+
// A confirmed /handoff moved the runtime to a fresh session: the first
209+
// upsert for a different session in that project is it — follow, or the
210+
// app stays on the old session while the backend moved on (issue #6).
211+
const pending = useAppShellStore.getState().pendingHandoff;
212+
if (pending
213+
&& Date.now() - pending.at < 120_000
214+
&& update.sessionId !== pending.fromSessionId
215+
&& (!pending.projectId || update.project?.projectId === pending.projectId)
216+
// unless the viewer deliberately moved on to a different session meanwhile.
217+
&& (!pending.fromSessionId || !sessionId || sessionId === pending.fromSessionId)) {
218+
useAppShellStore.getState().setPendingHandoff(null);
219+
navigate(`/session/${update.sessionId}`);
220+
}
208221
};
209222
return subscribe(receive);
210223
}, [client, navigate, sessionId, setSelectedProject, setSelectedSession, subscribe]);

src/stores/useAppShellStore.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export type AppShellState = {
1414
loadingProgress: LoadingProgress | null;
1515
/** The add-project dialog; opened from the sidebar and from the empty main pane alike. */
1616
newProjectOpen: boolean;
17+
/** Set when a /handoff gate is confirmed; the next session_upserted for a new session in that project navigates there. */
18+
pendingHandoff: { fromSessionId: string | null; projectId: string | undefined; at: number } | null;
1719
setSelectedProject: (next: Updater<Project | null>) => void;
1820
setSelectedSession: (next: Updater<ProjectSession | null>) => void;
1921
setActiveTab: (next: Updater<AppTab>) => void;
@@ -22,6 +24,7 @@ export type AppShellState = {
2224
setShowSettings: (next: Updater<boolean>) => void;
2325
setLoadingProgress: (next: Updater<LoadingProgress | null>) => void;
2426
setNewProjectOpen: (next: Updater<boolean>) => void;
27+
setPendingHandoff: (next: AppShellState['pendingHandoff']) => void;
2528
};
2629

2730
// 'shell'/'git'/'files' were removed as tabs (Files is a side panel now);
@@ -83,6 +86,7 @@ const createInitialState = (): AppShellState => ({
8386
settingsInitialTab: 'agents',
8487
loadingProgress: null,
8588
newProjectOpen: false,
89+
pendingHandoff: null,
8690
setSelectedProject: () => undefined,
8791
setSelectedSession: () => undefined,
8892
setActiveTab: () => undefined,
@@ -91,6 +95,7 @@ const createInitialState = (): AppShellState => ({
9195
setShowSettings: () => undefined,
9296
setLoadingProgress: () => undefined,
9397
setNewProjectOpen: () => undefined,
98+
setPendingHandoff: () => undefined,
9499
});
95100

96101
export const useAppShellStore = create<AppShellState>()((set) => ({
@@ -131,6 +136,7 @@ export const useAppShellStore = create<AppShellState>()((set) => ({
131136
setNewProjectOpen: (next) => set((state) => ({
132137
newProjectOpen: resolve(next, state.newProjectOpen),
133138
})),
139+
setPendingHandoff: (next) => set({ pendingHandoff: next }),
134140
}));
135141

136142
export const resetAppShellStore = () => {
@@ -144,5 +150,6 @@ export const resetAppShellStore = () => {
144150
setShowSettings: useAppShellStore.getState().setShowSettings,
145151
setLoadingProgress: useAppShellStore.getState().setLoadingProgress,
146152
setNewProjectOpen: useAppShellStore.getState().setNewProjectOpen,
153+
setPendingHandoff: useAppShellStore.getState().setPendingHandoff,
147154
}, true);
148155
};

0 commit comments

Comments
 (0)