Skip to content

Commit 66a35ae

Browse files
committed
fix(chat): resolve the workspace target authoritatively at send time
A send inside the debounce window previously created the session at the workspace root; allocation now awaits resolve-target with the submitted text, honors an explicit pin (including keep-at-root), and surfaces resolver failures instead of falling back to the root. Preview requests are project-scoped and text-capped, pins reset on project change, and cached project origin updates are monotonic so a stale index event can never demote an explicit project.
1 parent d47dfd8 commit 66a35ae

5 files changed

Lines changed: 111 additions & 12 deletions

File tree

‎src/components/chat/hooks/useChatComposerState.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,12 @@ export function useChatComposerState(args: UseChatComposerStateArgs) {
113113
const optionsFor = useCallback((text: string): QueuedSendOptions => ({ model: gjcModel, effort: reasoningEffort, sessionSummary: sessionLabel(selectedSession, text) }), [gjcModel, reasoningEffort, selectedSession]);
114114
const upload = useCallback(async (files: File[]) => { if (!files.length) return []; const body = new FormData(); files.forEach((file) => body.append('images', file)); const response = await authenticatedFetch('/api/assets/images', { method: 'POST', headers: {}, body }); if (!response.ok) throw new Error('Failed to upload images'); return (await response.json()).images as unknown[]; }, []);
115115
const workspaceTarget = useWorkspaceTarget({ selectedProject, selectedSession, currentSessionId, input });
116+
const { resolveForSend } = workspaceTarget;
116117
// A workspace root (e.g. `~/Projects`) never hosts a session itself: the
117118
// resolved child repo becomes the session's project, and it is what the
118119
// caller (ChatInterface's `establishSession`) registers into the sidebar.
119120
const descend = useCallback(async (target: WorkspaceCandidate): Promise<Project> => { const response = await authenticatedFetch(`/api/projects/${encodeURIComponent(selectedProject!.projectId)}/descend`, { method: 'POST', body: JSON.stringify({ path: target.path }) }); if (!response.ok) { const body = await response.json().catch(() => ({})) as { error?: string | { message?: string } }; const error = body.error; throw new Error(typeof error === 'string' ? error : error?.message ?? `Failed to switch to ${target.name} (${response.status})`); } return (await response.json())?.data as Project; }, [selectedProject]);
120-
const allocate = useCallback(async (summary: string | null) => { let id = selectedSession ? (selectedSession.__provider === 'gjc' ? selectedSession.id : null) : currentSessionId; if (id) return id; const target = workspaceTarget.isWorkspace ? workspaceTarget.target : null; const project = target ? await descend(target) : selectedProject; const response = await authenticatedFetch('/api/providers/sessions', { method: 'POST', body: JSON.stringify({ provider: 'gjc', projectPath: project?.fullPath || project?.path || '' }) }); if (!response.ok) throw new Error(`Failed to create session (${response.status})`); id = (await response.json())?.data?.sessionId || null; if (!id) throw new Error('no session id returned.'); onSessionEstablished?.(id, { provider: 'gjc', project: project!, summary }); return id; }, [currentSessionId, descend, onSessionEstablished, selectedProject, selectedSession, workspaceTarget.isWorkspace, workspaceTarget.target]);
121+
const allocate = useCallback(async (summary: string | null, text: string) => { let id = selectedSession ? (selectedSession.__provider === 'gjc' ? selectedSession.id : null) : currentSessionId; if (id) return id; const target = await resolveForSend(text); const project = target ? await descend(target) : selectedProject; const response = await authenticatedFetch('/api/providers/sessions', { method: 'POST', body: JSON.stringify({ provider: 'gjc', projectPath: project?.fullPath || project?.path || '' }) }); if (!response.ok) throw new Error(`Failed to create session (${response.status})`); id = (await response.json())?.data?.sessionId || null; if (!id) throw new Error('no session id returned.'); onSessionEstablished?.(id, { provider: 'gjc', project: project!, summary }); return id; }, [currentSessionId, descend, onSessionEstablished, resolveForSend, selectedProject, selectedSession]);
121122

122123
const handleSubmit = useCallback(async (event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => {
123124
event.preventDefault(); const text = inputRef.current; if (!text.trim() || !selectedProject) return;
@@ -126,7 +127,7 @@ export function useChatComposerState(args: UseChatComposerStateArgs) {
126127
const candidate = text.trimEnd(); const help = candidate.trim().toLowerCase() === 'help';
127128
if (candidate.startsWith('/') || help) { const gap = candidate.indexOf(' '); const name = help ? '/help' : gap > 0 ? candidate.slice(0, gap) : candidate; const commandArgs = gap > 0 ? candidate.slice(gap).trim() : ''; const app = findAppUiCommand(resolveCommandAlias(name)); if (app && (app.interceptWithArgs !== false || !commandArgs)) { clearComposer(); applyAppCommand(app); return; } const notice = getLocalCommandNotice(name, commandArgs); if (notice) { clearComposer(); addMessage({ type: 'assistant', content: notice, timestamp: Date.now() }); return; } if (!bypassGate.current) { const gate = gateForCommand(resolveCommandAlias(name), commandArgs); if (gate) { clearComposer(); announceGate({ ...gate, text: candidate }); return; } } bypassGate.current = false; const registered = slashCommands.find((item) => item.name === name); if (registered && !isAppUiCommand(registered) && registered.type !== 'skill' && registered.type !== 'provider') { void executeCommand(registered, help ? '/help' : candidate); clearComposer(); return; } }
128129
let images: unknown[]; try { images = await upload(attachedImages); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Image upload failed:', error); addMessage({ type: 'error', content: `Failed to upload images: ${message}`, timestamp: new Date() }); return; }
129-
const summary = sessionLabel(selectedSession, text); let id: string; try { id = await allocate(summary); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Session creation failed:', error); addMessage({ type: 'error', content: `Failed to start a new session: ${message}`, timestamp: new Date() }); return; }
130+
const summary = sessionLabel(selectedSession, text); let id: string; try { id = await allocate(summary, text); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Session creation failed:', error); addMessage({ type: 'error', content: `Failed to start a new session: ${message}`, timestamp: new Date() }); return; }
130131
addMessage({ type: 'user', content: text, images: images as any, timestamp: new Date() }); onSessionProcessing?.(id, { statusText: null, canInterrupt: true }); setIsUserScrolledUp(false); setTimeout(scrollToBottom, 100); sendMessage({ type: 'chat.send', sessionId: id, content: text, options: { ...optionsFor(text), images } }); clearComposer(); eraseDraft(id);
131132
}, [addMessage, allocate, announceGate, applyAppCommand, attachedImages, clearComposer, conversation, eraseDraft, executeCommand, isLoading, login, onSessionProcessing, optionsFor, resetCommandMenuState, scrollToBottom, selectedProject, selectedSession, sendMessage, setIsUserScrolledUp, slashCommands, upload]);
132133
useEffect(() => { submitRef.current = handleSubmit; }, [handleSubmit]);

‎src/components/chat/hooks/useWorkspaceTarget.dom.bun.test.tsx‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,62 @@ test('establishing a session releases the pin', async () => {
154154
fetch.restore();
155155
}
156156
});
157+
158+
test('resolveForSend authoritatively resolves submitted text before preview completes', async () => {
159+
const fetch = installFetch(({ url }) => {
160+
const text = new URL(url, 'http://local').searchParams.get('text') ?? '';
161+
return { isWorkspace: true, candidates: text ? [candidateA] : [] };
162+
});
163+
try {
164+
const view = composer();
165+
const target = await view.result.current.resolveForSend('exact submitted text');
166+
assert.equal(target?.name, 'repo-a');
167+
assert.equal(new URL(fetch.calls[1].url, 'http://local').searchParams.get('text'), 'exact submitted text');
168+
assert.equal(view.result.current.isWorkspace, false);
169+
} finally {
170+
fetch.restore();
171+
}
172+
});
173+
174+
test('resolveForSend honors a pinned root target without fetching', async () => {
175+
const fetch = installFetch(() => ({ isWorkspace: true, candidates: [] }));
176+
try {
177+
const view = composer();
178+
await waitFor(() => assert.equal(fetch.calls.length, 1));
179+
act(() => { view.result.current.pickTarget(null); });
180+
assert.equal(await view.result.current.resolveForSend('repo-a'), null);
181+
assert.equal(fetch.calls.length, 1);
182+
} finally {
183+
fetch.restore();
184+
}
185+
});
186+
187+
test('resolveForSend throws on an unsuccessful response', async () => {
188+
const original = globalThis.fetch;
189+
globalThis.fetch = (async () => new Response('', { status: 503 })) as typeof fetch;
190+
try {
191+
const view = composer();
192+
await assert.rejects(() => view.result.current.resolveForSend('repo-a'), /503/);
193+
} finally {
194+
globalThis.fetch = original;
195+
}
196+
});
197+
198+
test('a late response from a previous project cannot overwrite the current project', async () => {
199+
const original = globalThis.fetch;
200+
let resolveA: ((response: Response) => void) | undefined;
201+
globalThis.fetch = ((input: RequestInfo | URL) => {
202+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url;
203+
if (url.includes('proj-workspace')) return new Promise<Response>((resolve) => { resolveA = resolve; });
204+
return Promise.resolve(new Response(JSON.stringify({ data: { isWorkspace: true, candidates: [candidateB] } }), { status: 200 }));
205+
}) as typeof fetch;
206+
try {
207+
const view = composer();
208+
view.rerender({ selectedProject: { ...project, projectId: 'proj-b' } });
209+
await waitFor(() => assert.equal(view.result.current.candidates[0]?.name, 'repo-b'));
210+
await act(async () => { resolveA?.(new Response(JSON.stringify({ data: { isWorkspace: true, candidates: [candidateA] } }), { status: 200 })); });
211+
assert.equal(view.result.current.candidates[0]?.name, 'repo-b');
212+
} finally {
213+
globalThis.fetch = original;
214+
}
215+
});

‎src/components/chat/hooks/useWorkspaceTarget.ts‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ interface UseWorkspaceTargetResult {
2727
pickTarget: (candidate: WorkspaceCandidate | null) => void;
2828
/** True once the user has made an explicit pick that later typing must not override. */
2929
pinned: boolean;
30+
resolveForSend: (text: string) => Promise<WorkspaceCandidate | null>;
3031
}
3132

3233
/** A resolved candidate strong enough to auto-target without the user picking it. */
3334
const AUTO_TARGET_SCORE = 80;
3435
const RESOLVE_DEBOUNCE_MS = 300;
36+
const RESOLVE_TEXT_MAX = 2000;
3537

3638
/**
3739
* Resolves, for a new task started in a workspace-root project (e.g.
@@ -55,22 +57,35 @@ export function useWorkspaceTarget({ selectedProject, selectedSession, currentSe
5557

5658
const resolve = useCallback(async (text: string) => {
5759
if (!projectId) return;
58-
latestRequest.current = text;
60+
const cappedText = text.slice(0, RESOLVE_TEXT_MAX);
61+
const request = `${projectId}\n${cappedText}`;
62+
latestRequest.current = request;
5963
try {
60-
const response = await authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/resolve-target?text=${encodeURIComponent(text)}`);
61-
if (latestRequest.current !== text) return;
64+
const response = await authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/resolve-target?text=${encodeURIComponent(cappedText)}`);
65+
if (latestRequest.current !== request) return;
6266
if (!response.ok) { setIsWorkspace(false); setCandidates([]); return; }
6367
const body = await response.json();
64-
if (latestRequest.current !== text) return;
68+
if (latestRequest.current !== request) return;
6569
setIsWorkspace(Boolean(body?.data?.isWorkspace));
6670
setCandidates(Array.isArray(body?.data?.candidates) ? body.data.candidates : []);
6771
} catch {
68-
if (latestRequest.current !== text) return;
72+
if (latestRequest.current !== request) return;
6973
setIsWorkspace(false);
7074
setCandidates([]);
7175
}
7276
}, [projectId]);
7377

78+
const resolveForSend = useCallback(async (text: string): Promise<WorkspaceCandidate | null> => {
79+
if (!projectId || pinned) return pinnedTarget;
80+
const cappedText = text.slice(0, RESOLVE_TEXT_MAX);
81+
const response = await authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/resolve-target?text=${encodeURIComponent(cappedText)}`);
82+
if (!response.ok) throw new Error(`Failed to resolve workspace target (${response.status})`);
83+
const body = await response.json();
84+
if (!body?.data?.isWorkspace) return null;
85+
const candidates = Array.isArray(body?.data?.candidates) ? body.data.candidates as WorkspaceCandidate[] : [];
86+
return candidates.length && candidates[0].score >= AUTO_TARGET_SCORE ? candidates[0] : null;
87+
}, [pinned, pinnedTarget, projectId]);
88+
7489
// The empty-text probe: once per new-task mount/project change, tells the
7590
// composer whether there is anything to resolve at all.
7691
useEffect(() => {
@@ -99,6 +114,11 @@ export function useWorkspaceTarget({ selectedProject, selectedSession, currentSe
99114
}
100115
}, [currentSessionId, input, selectedSession]);
101116

117+
useEffect(() => {
118+
setPinned(false);
119+
setPinnedTarget(null);
120+
}, [projectId]);
121+
102122
const pickTarget = useCallback((candidate: WorkspaceCandidate | null) => {
103123
setPinnedTarget(candidate);
104124
setPinned(true);
@@ -107,5 +127,5 @@ export function useWorkspaceTarget({ selectedProject, selectedSession, currentSe
107127
const autoTarget = candidates.length > 0 && candidates[0].score >= AUTO_TARGET_SCORE ? candidates[0] : null;
108128
const target = pinned ? pinnedTarget : autoTarget;
109129

110-
return { isWorkspace, candidates, target, pickTarget, pinned };
130+
return { isWorkspace, candidates, target, pickTarget, pinned, resolveForSend };
111131
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,26 @@ test('an upsert carrying an origin promotes the cached project so the sidebar ca
202202
}
203203
});
204204

205+
test('upsert origin only promotes cached projects to explicit', async () => {
206+
const fetch = installFetch([{ body: [{ ...project(), origin: 'explicit' }] }]);
207+
try {
208+
const harness = renderHarness();
209+
await waitFor(() => assert.equal(harness.getState().projects.length, 1));
210+
act(() => harness.emit({
211+
kind: 'session_upserted', sessionId: 'auto-event', provider: 'gjc', session: { id: 'auto-event' },
212+
project: { ...project(), origin: 'auto' }, timestamp: new Date().toISOString(),
213+
} as ServerEvent));
214+
await waitFor(() => assert.equal(harness.getState().projects[0]?.origin, 'explicit'));
215+
act(() => harness.emit({
216+
kind: 'session_upserted', sessionId: 'explicit-event', provider: 'gjc', session: { id: 'explicit-event' },
217+
project: { ...project(), origin: 'explicit' }, timestamp: new Date().toISOString(),
218+
} as ServerEvent));
219+
assert.equal(harness.getState().projects[0]?.origin, 'explicit');
220+
} finally {
221+
fetch.restore();
222+
}
223+
});
224+
205225
test('an upsert for the viewed session renames it where the header reads it, not only in the sidebar', async () => {
206226
const fetch = installFetch([{ body: [project()] }]);
207227
try {

‎src/hooks/useProjectsState.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,10 @@ const updateProjectCache = (projects: Project[], event: SessionUpsert): Project[
9999
? project.projectId === projectId
100100
: rowsOf(project).some((session) => session.id === event.sessionId));
101101
if (found) {
102-
// A row the indexer discovered ('auto') can become a sidebar project the
103-
// moment a session lands in it (a workspace descend promotes it), so the
104-
// event's origin wins over the cached one.
102+
// The database only promotes discovered rows ('auto'/'legacy') to explicit;
103+
// a later index event must never demote an explicit cached project.
105104
const origin = event.project?.origin;
106-
const promoted = origin && origin !== found.origin ? { ...found, origin } : found;
105+
const promoted = origin === 'explicit' && found.origin !== 'explicit' ? { ...found, origin } : found;
107106
const next = applySessionUpsert(promoted, event);
108107
return next === found ? projects : projects.map((project) => project === found ? next : project);
109108
}

0 commit comments

Comments
 (0)