merge(main): resolve skills.test.js import conflict

Keep both discoverSkills from main and renameSkill from this branch.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 09:55:56 +00:00
co-authored by Serhii Dziupin
43 changed files with 1969 additions and 244 deletions
@@ -111,7 +111,15 @@ const EMPTY_QUEUE: QueuedMessage[] = [];
export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: QueuedMessageChipsProps) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
// Must use the same resolution the composer used to build the queue key —
// reading currentSessionDirectory raw can key the chips to a different
// directory than the one the messages were queued under.
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const target = currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectory) : null;
const queueKey = target ? getMessageQueueKey(target) : null;
const queuedMessages = useMessageQueueStore(
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { renderTerminalOutput } from './toolOutput';
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
import { tryParseJsonOutput } from '../toolRenderers';
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
@@ -7,18 +8,73 @@ import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
import { getToolDescriptionFallback } from './toolRenderUtils';
describe('getToolOutput', () => {
test('prefers authoritative state output', () => {
expect(getToolOutput('bash', 'final output', 'streamed output')).toBe('final output');
expect(getToolOutput('bash', '', 'streamed output')).toBe('');
test('prefers state.output for completed tools', () => {
expect(getToolOutput('bash', 'final output', 'partial output', 'completed')).toBe('final output');
});
test('falls back to streamed metadata output for bash', () => {
expect(getToolOutput('bash', undefined, 'streamed output')).toBe('streamed output');
expect(getToolOutput('bash', undefined, '')).toBe(undefined);
test('normalizes completed bash state output while preserving final-output precedence', () => {
expect(getToolOutput('bash', '\u001B[32mFinal output\u001B[0m', 'partial output', 'completed')).toBe('Final output');
});
test('does not expose metadata output for other tools', () => {
expect(getToolOutput('read', undefined, 'metadata output')).toBe(undefined);
test('falls back to metadata.output for bash tools without state output', () => {
expect(getToolOutput('bash', undefined, 'partial output', 'completed')).toBe('partial output');
});
test('normalizes bash metadata output for completed state', () => {
expect(getToolOutput('bash', undefined, 'Progress 10%\r\u001B[2KProgress 90%', 'completed')).toBe('Progress 90%');
});
test('does not normalize bash output while running', () => {
expect(getToolOutput('bash', '\u001B[32mRunning\u001B[0m', undefined, 'running')).toBe('\u001B[32mRunning\u001B[0m');
expect(getToolOutput('bash', undefined, 'Progress\r\u001B[2K', 'running')).toBe('Progress\r\u001B[2K');
});
test('ignores metadata.output for non-bash tools', () => {
expect(getToolOutput('read', undefined, 'partial output', 'completed')).toBe(undefined);
expect(getToolOutput('read', 'final output', 'partial output', 'completed')).toBe('final output');
});
test('returns undefined when bash has no output', () => {
expect(getToolOutput('bash', undefined, undefined, 'completed')).toBe(undefined);
});
test('ignores empty metadata.output for bash', () => {
expect(getToolOutput('bash', undefined, '', 'completed')).toBe(undefined);
});
});
describe('renderTerminalOutput', () => {
test('renders carriage-return progress updates as their latest value', () => {
expect(renderTerminalOutput('Downloading 10%\r\u001B[2KDownloading 90%')).toBe('Downloading 90%');
});
test('removes ANSI styles while preserving the output text', () => {
expect(renderTerminalOutput('\u001B[32mComplete\u001B[0m\n')).toBe('Complete\n');
});
test('applies cursor-up progress updates to the prior line', () => {
expect(renderTerminalOutput('First\nWorking\u001B[1A\r\u001B[2KDone\n')).toBe('Done\nWorking');
});
test('CSI K erases from cursor to end of line', () => {
expect(renderTerminalOutput('Hello World\u001B[5G\u001B[K')).toBe('Hell');
});
test('CSI 1 K erases from beginning of line through cursor, preserving suffix', () => {
expect(renderTerminalOutput('Hello World\u001B[6G\u001B[1K')).toBe(' World');
});
test('CSI 2 K erases entire line', () => {
expect(renderTerminalOutput('Hello World\u001B[2K')).toBe('');
});
test('handles large single-line output without quadratic slowdown', () => {
const largeLine = 'A'.repeat(50000) + '\u001B[0m';
const start = performance.now();
const result = renderTerminalOutput(largeLine);
const elapsed = performance.now() - start;
expect(result).toBe('A'.repeat(50000));
expect(elapsed).toBeLessThan(1000);
});
});
@@ -1362,7 +1362,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output);
const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output, state.status);
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
const rawOutputString = typeof rawOutput === 'string' ? rawOutput : '';
const isStreamingBash = part.tool === 'bash' && state.status === 'running';
@@ -1,14 +1,133 @@
const ensureLine = (lines: string[][], row: number): void => {
while (lines.length <= row) {
lines.push([]);
}
};
const writeTerminalCharacter = (lines: string[][], row: number, column: number, character: string): void => {
ensureLine(lines, row);
const line = lines[row];
while (line.length < column) {
line.push(' ');
}
line[column] = character;
};
export const renderTerminalOutput = (output: string): string => {
if (!output.includes('\u001B') && !output.includes('\r') && !output.includes('\b')) {
return output;
}
const lines: string[][] = [[]];
let row = 0;
let column = 0;
for (let index = 0; index < output.length; index += 1) {
const character = output[index];
if (character === '\n') {
row += 1;
column = 0;
ensureLine(lines, row);
continue;
}
if (character === '\r') {
column = 0;
continue;
}
if (character === '\b') {
column = Math.max(0, column - 1);
continue;
}
if (character !== '\u001B') {
writeTerminalCharacter(lines, row, column, character);
column += 1;
continue;
}
const nextCharacter = output[index + 1];
if (nextCharacter === '[') {
const sequenceStart = index + 2;
let sequenceEnd = sequenceStart;
while (sequenceEnd < output.length && !/[\x40-\x7E]/.test(output[sequenceEnd])) {
sequenceEnd += 1;
}
if (sequenceEnd === output.length) {
break;
}
const command = output[sequenceEnd];
const parameters = output.slice(sequenceStart, sequenceEnd).split(';').map((value) => Number.parseInt(value, 10) || 0);
const count = parameters[0] || 1;
if (command === 'A') {
row = Math.max(0, row - count);
} else if (command === 'B') {
row += count;
ensureLine(lines, row);
} else if (command === 'C') {
column += count;
} else if (command === 'D') {
column = Math.max(0, column - count);
} else if (command === 'G') {
column = Math.max(0, count - 1);
} else if (command === 'H' || command === 'f') {
row = Math.max(0, (parameters[0] || 1) - 1);
column = Math.max(0, (parameters[1] || 1) - 1);
ensureLine(lines, row);
} else if (command === 'K') {
ensureLine(lines, row);
const line = lines[row];
const mode = parameters[0];
if (mode === 1) {
for (let i = 0; i <= column && i < line.length; i += 1) {
line[i] = ' ';
}
} else if (mode === 2) {
lines[row] = [];
} else {
line.length = Math.min(line.length, column);
}
}
index = sequenceEnd;
continue;
}
if (nextCharacter === ']') {
const terminator = output.indexOf('\u0007', index + 2);
const stringTerminator = output.indexOf('\u001B\\', index + 2);
const end = terminator === -1
? stringTerminator
: stringTerminator === -1
? terminator
: Math.min(terminator, stringTerminator);
if (end === -1) {
break;
}
index = output[end] === '\u0007' ? end : end + 1;
continue;
}
index += 1;
}
return lines.map((line) => line.join('')).join('\n');
};
export const getToolOutput = (
tool: string,
stateOutput: unknown,
metadataOutput: unknown,
status?: string,
): string | undefined => {
const isBash = tool === 'bash';
const shouldNormalize = isBash && status !== 'running';
if (typeof stateOutput === 'string') {
return stateOutput;
return shouldNormalize ? renderTerminalOutput(stateOutput) : stateOutput;
}
if (tool === 'bash' && typeof metadataOutput === 'string' && metadataOutput.length > 0) {
return metadataOutput;
if (isBash && typeof metadataOutput === 'string' && metadataOutput.length > 0) {
return shouldNormalize ? renderTerminalOutput(metadataOutput) : metadataOutput;
}
return undefined;
@@ -18,6 +18,42 @@ import type { TerminalChunk } from '@/stores/useTerminalStore';
let ghosttyPromise: Promise<Ghostty> | null = null;
const loadGhostty = (): Promise<Ghostty> => ghosttyPromise ??= Ghostty.load();
type TerminalSize = { cols: number; rows: number };
const getProvisionalTerminalSize = (
container: HTMLDivElement,
fontFamily: string,
fontSize: number,
): TerminalSize | null => {
if (typeof window === 'undefined' || typeof document === 'undefined') return null;
const context = document.createElement('canvas').getContext('2d');
if (!context || container.clientWidth < 24 || container.clientHeight < 24) return null;
context.font = `${fontSize}px ${fontFamily}`;
const metrics = context.measureText('M');
const cellWidth = Math.ceil(metrics.width);
const cellHeight = Math.ceil(
(metrics.actualBoundingBoxAscent || fontSize * 0.8) +
(metrics.actualBoundingBoxDescent || fontSize * 0.2),
) + 2;
if (cellWidth < 1 || cellHeight < 1) return null;
const style = window.getComputedStyle(container);
const horizontalPadding =
(Number.parseInt(style.paddingLeft, 10) || 0) +
(Number.parseInt(style.paddingRight, 10) || 0);
const verticalPadding =
(Number.parseInt(style.paddingTop, 10) || 0) +
(Number.parseInt(style.paddingBottom, 10) || 0);
// Match Ghostty FitAddon's 15px scrollbar reservation and minimum dimensions.
return {
cols: Math.max(2, Math.floor((container.clientWidth - horizontalPadding - 15) / cellWidth)),
rows: Math.max(1, Math.floor((container.clientHeight - verticalPadding) / cellHeight)),
};
};
export type TerminalController = {
focus: () => void;
fit: () => void;
@@ -47,7 +83,8 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
const fitRef = React.useRef<FitAddon | null>(null);
const inputRef = React.useRef(onInput);
const resizeRef = React.useRef(onResize);
const lastSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const lastSizeRef = React.useRef<TerminalSize | null>(null);
const provisionalSizeRef = React.useRef<TerminalSize | null>(null);
const lastChunkRef = React.useRef<number | null>(null);
const writeQueueRef = React.useRef('');
const outputRewriteCarryRef = React.useRef('');
@@ -65,6 +102,14 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
visibleRef.current = isVisible;
safeResetRef.current = getGhosttySafeResetSequence(theme.background);
React.useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const size = getProvisionalTerminalSize(container, fontFamily, fontSize);
provisionalSizeRef.current = size;
if (size) resizeRef.current(size.cols, size.rows);
}, [fontFamily, fontSize]);
const fit = React.useCallback(() => {
const container = containerRef.current;
const terminal = terminalRef.current;
@@ -168,7 +213,10 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
loadGhostty().then((ghostty) => {
if (disposed) return;
terminal = new GhosttyTerminal(getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false));
terminal = new GhosttyTerminal({
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
...(provisionalSizeRef.current ?? {}),
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(container);
@@ -26,6 +26,8 @@ type TerminalViewProps = {
visible?: boolean;
};
const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;
export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const { t } = useI18n();
const { terminal, runtime } = useRuntimeAPIs();
@@ -109,7 +111,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
const [isRestarting, setIsRestarting] = React.useState(false);
const [hasViewportSize, setHasViewportSize] = React.useState(false);
const streamCleanupRef = React.useRef<(() => void) | null>(null);
const activeTerminalIdRef = React.useRef<string | null>(null);
@@ -118,7 +119,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const directoryRef = React.useRef<string | null>(effectiveDirectory);
const terminalControllerRef = React.useRef<TerminalController | null>(null);
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const isTerminalVisibleRef = React.useRef(false);
const pendingTerminalCreatesRef = React.useRef(new Set<string>());
const previewScanTailRef = React.useRef('');
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
const previewProbeGenerationRef = React.useRef(0);
@@ -157,10 +158,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
}
}, [isTerminalVisible]);
React.useEffect(() => {
isTerminalVisibleRef.current = isTerminalVisible;
}, [isTerminalVisible]);
React.useEffect(() => {
terminalIdRef.current = terminalSessionId;
}, [terminalSessionId]);
@@ -424,7 +421,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
}
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
let terminalId = tab?.terminalSessionId ?? null;
const terminalId = tab?.terminalSessionId ?? null;
const terminalLifecycle = tab?.lifecycle ?? 'idle';
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
const buffer = useTerminalStore.getState().getBuffer(directory, tabId);
@@ -441,11 +438,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
return;
}
const size = lastViewportSizeRef.current;
if (!size && isTerminalVisibleRef.current) {
const createKey = `${directory}\u0000${tabId}`;
if (pendingTerminalCreatesRef.current.has(createKey)) {
return;
}
// Launch the shell while Ghostty is still loading and fitting.
// The backend accepts 80x24, then receives the measured size as
// soon as the viewport is ready.
const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
pendingTerminalCreatesRef.current.add(createKey);
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
@@ -454,8 +457,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const session = await terminal.createSession({
cwd: directory,
sessionId: tabId,
cols: size?.cols,
rows: size?.rows,
cols: initialSize.cols,
rows: initialSize.rows,
shell: terminalShell,
loginShell: terminalLoginShell,
...terminalAppearanceRef.current,
@@ -476,19 +479,38 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
setTabSessionId(directory, tabId, session.sessionId);
if (!stillActive) return;
terminalId = session.sessionId;
} catch (error) {
if (!cancelled) {
setConnectionError(
error instanceof Error
? error.message
: t('terminalView.error.startSessionFailed')
);
setIsFatalError(true);
setIsReconnectPending(false);
setConnecting(directory, tabId, false);
const viewportSize = lastViewportSizeRef.current;
if (
viewportSize &&
(viewportSize.cols !== initialSize.cols || viewportSize.rows !== initialSize.rows)
) {
void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {});
}
// Storing the session ID reruns this effect. Let that next
// effect own stream startup: starting here would be torn
// down immediately by this effect's cleanup.
return;
} catch (error) {
const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId);
if (!owningTab || owningTab.terminalSessionId) return;
setConnecting(directory, tabId, false);
// Strict Mode replaces the first effect while its create
// request is pending. `cancelled` therefore does not mean
// this tab stopped owning the request; use current store
// ownership so a rejected create cannot leave it spinning.
if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return;
setConnectionError(
error instanceof Error
? error.message
: t('terminalView.error.startSessionFailed')
);
setIsFatalError(true);
setIsReconnectPending(false);
return;
} finally {
pendingTerminalCreatesRef.current.delete(createKey);
}
}
@@ -513,7 +535,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
terminalLifecycle,
activeTabId,
hasOpenedTerminalViewport,
hasViewportSize,
enableTabs,
terminalHydrated,
ensureDirectory,
@@ -568,7 +589,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
resetTerminalPreviewScan();
try {
const size = lastViewportSizeRef.current ?? { cols: 80, rows: 24 };
const size = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
const restarted = await terminal.restartSession(originalSessionId, { cwd: effectiveDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current });
const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId);
if (owningTab?.terminalSessionId !== originalSessionId) return;
@@ -694,22 +715,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const handleViewportResize = React.useCallback(
(cols: number, rows: number) => {
const previous = lastViewportSizeRef.current;
if (!previous) {
lastViewportSizeRef.current = { cols, rows };
if (!terminalIdRef.current) setHasViewportSize(true);
} else if (previous.cols !== cols || previous.rows !== rows) {
if (!previous || previous.cols !== cols || previous.rows !== rows) {
lastViewportSizeRef.current = { cols, rows };
}
if (!isTerminalVisibleRef.current) {
if (!isTerminalVisible) {
return;
}
const terminalId = terminalIdRef.current;
if (!terminalId) return;
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {
});
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {});
},
[terminal]
[isTerminalVisible, terminal]
);
const handleModifierToggle = React.useCallback(
@@ -801,11 +817,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
// here tore down and rebuilt the Ghostty terminal (WASM VT + canvas + font
// atlas) a second time the moment `createSession` resolved, doubling the cost
// of every terminal open. Session changes are handled by the chunk replay path.
const terminalViewportKey = React.useMemo(() => {
const directoryPart = effectiveDirectory ?? 'no-dir';
const tabPart = activeTabId ?? 'no-tab';
return `${directoryPart}::${tabPart}`;
}, [effectiveDirectory, activeTabId]);
const terminalViewportKey = `${effectiveDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`;
React.useEffect(() => {
if (!isTerminalVisible || useTouchTerminalInput) {
@@ -2,8 +2,8 @@
* Regression guard for slow terminal opening on Linux.
*
* `TerminalViewport` is keyed by `terminalViewportKey`. That key used to include
* the PTY session id, which is null until `createSession` resolves. Because the
* viewport must mount first to report its size before a session can be created,
* the PTY session id, which is null until `createSession` resolves. Historically,
* the viewport had to mount first to report its size before session creation, so
* every terminal open built a Ghostty terminal (WASM VT + 2D canvas renderer +
* font atlas), threw it away when the session id arrived, and built a second one.
* The same churn repeated on reconnect and on every incidental session-id change,
@@ -12,6 +12,8 @@
*
* Viewport identity must therefore be directory + tab only. Session changes are
* handled by the chunk replay path, which resets the existing terminal in place.
* New sessions start concurrently with a container-derived size (or 80x24) and
* resize after their viewport fits.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
@@ -25,27 +27,15 @@ const terminalViewportSource = readFileSync(
'utf-8',
);
const viewportKeyBlock = (() => {
const start = terminalViewSource.indexOf('const terminalViewportKey = React.useMemo(');
expect(start).toBeGreaterThan(-1);
const end = terminalViewSource.indexOf('}, [', start);
expect(end).toBeGreaterThan(start);
return terminalViewSource.slice(start, terminalViewSource.indexOf(');', end));
})();
const viewportKeyDeclaration = terminalViewSource
.split('\n')
.find((line) => line.includes('const terminalViewportKey =')) ?? '';
describe('terminal viewport remount guard', () => {
test('viewport identity excludes the PTY session id', () => {
expect(viewportKeyBlock).toContain('effectiveDirectory');
expect(viewportKeyBlock).toContain('activeTabId');
expect(viewportKeyBlock).not.toContain('terminalSessionId');
});
test('viewport key memo does not depend on the PTY session id', () => {
const dependencyStart = terminalViewSource.indexOf('}, [', terminalViewSource.indexOf('const terminalViewportKey'));
const dependencies = terminalViewSource.slice(dependencyStart, terminalViewSource.indexOf(']', dependencyStart));
expect(dependencies).toContain('effectiveDirectory');
expect(dependencies).toContain('activeTabId');
expect(dependencies).not.toContain('terminalSessionId');
expect(viewportKeyDeclaration).toContain('effectiveDirectory');
expect(viewportKeyDeclaration).toContain('activeTabId');
expect(viewportKeyDeclaration).not.toContain('terminalSessionId');
});
test('replay discontinuities reset the terminal in place instead of remounting it', () => {
@@ -61,4 +51,53 @@ describe('terminal viewport remount guard', () => {
expect(terminalViewSource).toContain('getBuffer(');
expect(terminalViewSource).not.toContain('activeTab?.bufferChunks');
});
test('starts the PTY before Ghostty reports its first viewport size', () => {
expect(terminalViewSource).toContain('const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;');
expect(terminalViewSource).toContain('const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;');
expect(terminalViewSource).not.toContain('if (!size && isTerminalVisibleRef.current)');
expect(terminalViewSource).toContain('cols: initialSize.cols');
expect(terminalViewSource).toContain('rows: initialSize.rows');
expect(terminalViewSource).toContain('void terminal.resize({ sessionId: session.sessionId, ...viewportSize })');
expect(terminalViewSource).toContain('if (!isTerminalVisible) {');
expect(terminalViewSource).not.toContain('isTerminalVisibleRef');
});
test('deduplicates create attempts while the viewport layout settles', () => {
expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.has(createKey)');
expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.delete(createKey)');
});
test('lets the session-ID effect own stream startup after creating a tab', () => {
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
const createEnd = terminalViewSource.indexOf('if (!terminalId || cancelled) return;', createStart);
expect(createStart).toBeGreaterThan(-1);
expect(createEnd).toBeGreaterThan(createStart);
const createBlock = terminalViewSource.slice(createStart, createEnd);
expect(createBlock).toContain('setTabSessionId(directory, tabId, session.sessionId);');
expect(createBlock).toContain('Let that next');
expect(createBlock).not.toContain('startStream(');
});
test('clears a current tab from connecting when a strict-mode create rejects', () => {
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
const catchStart = terminalViewSource.indexOf('} catch (error) {', createStart);
const catchEnd = terminalViewSource.indexOf('} finally {', catchStart);
expect(catchStart).toBeGreaterThan(createStart);
expect(catchEnd).toBeGreaterThan(catchStart);
const catchBlock = terminalViewSource.slice(catchStart, catchEnd);
expect(catchBlock).toContain('owningTab.terminalSessionId');
expect(catchBlock).toContain('activeTabIdRef.current !== tabId');
expect(catchBlock).toContain('setConnecting(directory, tabId, false);');
expect(catchBlock).not.toContain('if (!cancelled)');
});
test('derives the initial PTY size before Ghostty mounts', () => {
expect(terminalViewportSource).toContain('const getProvisionalTerminalSize');
expect(terminalViewportSource).toContain('React.useLayoutEffect(() => {');
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
});
});
@@ -29,12 +29,61 @@ mock.module('@/sync/session-ui-store', () => ({
import {
buildQueuedAutoSendPayload,
createQueuedAutoSendRetryScheduler,
getQueuedAutoSendRetryDelayMs,
isQueuedAutoSendBackedOff,
sendQueuedAutoSendPayload,
shouldDispatchQueuedAutoSend,
} from './useQueuedMessageAutoSend';
describe('queued auto-send retry scheduler', () => {
test('wakes the queue when backoff expires', () => {
const callbacks = new Map<number, () => void>();
let nextTimer = 0;
let wakeups = 0;
const scheduler = createQueuedAutoSendRetryScheduler(
() => { wakeups += 1; },
() => 1_000,
(callback, delay) => {
callbacks.set(++nextTimer, callback);
expect(delay).toBe(500);
return nextTimer as unknown as ReturnType<typeof setTimeout>;
},
(timer) => { callbacks.delete(timer as unknown as number); },
);
scheduler.schedule(1_500);
expect(callbacks.size).toBe(1);
callbacks.values().next().value?.();
expect(wakeups).toBe(1);
});
test('keeps the earliest retry and cancels it on dispose', () => {
const callbacks = new Map<number, () => void>();
let nextTimer = 0;
const delays: number[] = [];
const scheduler = createQueuedAutoSendRetryScheduler(
() => undefined,
() => 1_000,
(callback, delay) => {
callbacks.set(++nextTimer, callback);
delays.push(delay);
return nextTimer as unknown as ReturnType<typeof setTimeout>;
},
(timer) => { callbacks.delete(timer as unknown as number); },
);
scheduler.schedule(3_000);
scheduler.schedule(4_000);
scheduler.schedule(2_000);
expect(delays).toEqual([2_000, 1_000]);
expect(callbacks.size).toBe(1);
scheduler.dispose();
expect(callbacks.size).toBe(0);
});
});
describe('shouldDispatchQueuedAutoSend', () => {
test('dispatches only after an active session becomes idle', () => {
expect(shouldDispatchQueuedAutoSend('busy', 'idle', false)).toBe(true);
@@ -33,12 +33,47 @@ export const isQueuedAutoSendBackedOff = (
now: number,
): boolean => failure !== undefined && failure.messageId === messageId && now < failure.nextAttemptAt;
const hasRecentAbort = (sessionId: string): boolean => {
export const createQueuedAutoSendRetryScheduler = (
onWake: () => void,
now: () => number = Date.now,
scheduleTimeout: (callback: () => void, delay: number) => ReturnType<typeof setTimeout> = setTimeout,
cancelTimeout: (timer: ReturnType<typeof setTimeout>) => void = clearTimeout,
) => {
let timer: ReturnType<typeof setTimeout> | null = null;
let scheduledAt: number | null = null;
return {
schedule(retryAt: number) {
if (scheduledAt !== null && scheduledAt <= retryAt) return;
if (timer !== null) cancelTimeout(timer);
scheduledAt = retryAt;
timer = scheduleTimeout(() => {
timer = null;
scheduledAt = null;
onWake();
}, Math.max(0, retryAt - now()));
},
dispose() {
if (timer !== null) cancelTimeout(timer);
timer = null;
scheduledAt = null;
},
};
};
/**
* When the abort window is still open, returns the time it expires so the
* caller can wake the queue then. Returns `null` once sending is allowed
* again — a queued item must not wait for an unrelated state change to be
* retried after the window closes.
*/
const getAbortHoldUntil = (sessionId: string): number | null => {
const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId);
if (!abortRecord) {
return false;
return null;
}
return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS;
const holdUntil = abortRecord.timestamp + RECENT_ABORT_WINDOW_MS;
return Date.now() < holdUntil ? holdUntil : null;
};
export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => {
@@ -149,6 +184,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
const sendFailuresRef = React.useRef<Map<string, QueuedAutoSendFailure>>(new Map());
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(new Map());
const autoReviewBlockedSessionsRef = React.useRef<Set<string>>(new Set());
const [retryTick, setRetryTick] = React.useState(0);
const retryScheduler = React.useMemo(
() => createQueuedAutoSendRetryScheduler(() => setRetryTick((value) => value + 1)),
[],
);
React.useEffect(() => () => retryScheduler.dispose(), [retryScheduler]);
React.useEffect(() => {
if (!enabled) {
@@ -164,7 +206,9 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
if (inFlightSessionsRef.current.has(targetKey)) {
return;
}
if (hasRecentAbort(sessionId)) {
const abortHoldUntil = getAbortHoldUntil(sessionId);
if (abortHoldUntil !== null) {
retryScheduler.schedule(abortHoldUntil);
return;
}
if (useAutoReviewStore.getState().isRunningForSession(sessionId)) {
@@ -185,7 +229,8 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
const failure = sendFailuresRef.current.get(targetKey);
if (failure && failure.messageId !== payload.queuedMessageId) {
sendFailuresRef.current.delete(targetKey);
} else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
} else if (failure && isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
retryScheduler.schedule(failure.nextAttemptAt);
return;
}
@@ -195,6 +240,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
? captured
: resolveSessionSendConfig(sessionId);
if (!resolved.providerID || !resolved.modelID) {
// Legacy queues may predate captured send configuration. Config
// hydration is asynchronous, so retry instead of stranding the item
// until an unrelated status or directory update happens.
retryScheduler.schedule(Date.now() + AUTO_SEND_RETRY_BASE_DELAY_MS);
return;
}
@@ -213,11 +262,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
console.warn('[queue] queued auto-send failed:', error);
const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0;
const failures = priorFailures + 1;
const nextAttemptAt = Date.now() + getQueuedAutoSendRetryDelayMs(failures);
sendFailuresRef.current.set(targetKey, {
messageId: payload.queuedMessageId,
failures,
nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures),
nextAttemptAt,
});
retryScheduler.schedule(nextAttemptAt);
} finally {
inFlightSessionsRef.current.delete(targetKey);
}
@@ -257,5 +308,5 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
});
previousStatusRef.current = nextStatusMap;
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory]);
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory, retryTick, retryScheduler]);
}
+106 -2
View File
@@ -1,12 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionUIStore, getRememberedSessionDirectory } from '@/sync/session-ui-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { checkIsGitRepository } from '@/lib/gitApi';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard';
import { getSyncSessions, getSyncMessages, getSyncParts } from '@/sync/sync-refs';
import { getSyncSessions, getSyncMessages, getSyncParts, getAllSyncSessions, getSyncSessionDirectory } from '@/sync/sync-refs';
import {
describeSessionDirectorySources,
resolveSessionDirectoryFromSources,
} from '@/sync/session-directory-resolution';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { getRecentSendFailures } from '@/sync/send-failure-log';
import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract';
import { useStreamingStore } from '@/sync/streaming';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -375,12 +382,107 @@ export const debugUtils = {
openchamber: {
settingsInfo,
},
// Empty is a meaningful answer here: it means no prompt was rejected in
// this session, so a "my message disappeared" report is not a rejected
// send and needs a different explanation.
recentSendFailures: getRecentSendFailures(),
currentSessionDirectoryResolution: sessionState.currentSessionId
? this.diagnoseSessionDirectory(sessionState.currentSessionId)
: null,
};
console.log('[DEBUG] App status snapshot:', report);
return report;
},
/**
* Prompt sends that were rejected and rolled back in this app session.
* Newest first; empty means no send was rejected.
*/
getRecentSendFailures() {
const failures = getRecentSendFailures();
if (failures.length === 0) {
console.log('[OK] No prompt sends were rejected in this session.');
} else {
console.warn(`[ALERT] ${failures.length} rejected prompt send(s):`);
console.table(failures);
}
return failures;
},
/**
* Report how a session's directory is resolved, from every source, in
* precedence order. A send is routed by the winning value, so a disagreement
* here explains a prompt that vanishes without an error: it was posted
* against a directory that does not own the session.
*/
diagnoseSessionDirectory(sessionId?: string) {
const sessionState = useSessionUIStore.getState();
const targetSessionId = sessionId ?? sessionState.currentSessionId;
if (!targetSessionId) {
console.log('[ERROR] No session selected and no session id passed');
return null;
}
const attachment = getAttachedSessionDirectory(
useSessionWorktreeStore.getState().getAttachment(targetSessionId),
);
const worktreeMetadata = sessionState.worktreeMetadata.get(targetSessionId)?.path ?? null;
const owningStoreDirectory = getSyncSessionDirectory(targetSessionId);
const sessionRecord = getAllSyncSessions().find((session) => session.id === targetSessionId);
const recordDirectory = (sessionRecord as { directory?: string | null } | undefined)?.directory ?? null;
const selected = targetSessionId === sessionState.currentSessionId
? sessionState.currentSessionDirectory
: null;
const remembered = getRememberedSessionDirectory(targetSessionId);
const sources = {
attachment,
worktreeMetadata,
authoritative: owningStoreDirectory ?? recordDirectory,
selected,
remembered: remembered.runtime,
};
const resolution = resolveSessionDirectoryFromSources(sources);
const routedDirectory = sessionState.getDirectoryForSession(targetSessionId);
const report = {
sessionId: targetSessionId,
isCurrentSession: targetSessionId === sessionState.currentSessionId,
routedDirectory,
resolvedFrom: resolution.source,
conflict: resolution.conflict,
sources: describeSessionDirectorySources(sources),
details: {
owningChildStore: owningStoreDirectory,
sessionRecordDirectory: recordDirectory,
sessionIndexed: Boolean(sessionRecord),
currentSessionDirectory: sessionState.currentSessionDirectory,
rememberedForRuntime: remembered.runtime,
persistedAcrossRestarts: remembered.persisted,
activeDirectory: useDirectoryStore.getState().currentDirectory ?? null,
opencodeClientDirectory: opencodeClient.getDirectory() ?? null,
},
};
console.log('[DEBUG] Session directory resolution:', report);
if (resolution.conflict) {
console.warn(
`[ALERT] Directory sources disagree: using "${resolution.directory}" (${resolution.source}) `
+ `while "${resolution.conflict.directory}" came from ${resolution.conflict.source}.`,
);
} else if (!routedDirectory) {
console.warn('[ALERT] No directory resolved for this session — sends fall back to the active directory.');
} else {
console.log('[OK] All known sources agree on the session directory.');
}
return report;
},
async buildDiagnosticsReport() {
const report = await this.getAppStatus();
return JSON.stringify(report, null, 2);
@@ -697,6 +799,8 @@ if (typeof window !== 'undefined') {
console.log(' __opencodeDebug.getAllMessages(truncate?) - List all messages (truncate=true for short preview)');
console.log(' __opencodeDebug.truncateMessages(messages) - Truncate long fields in messages array');
console.log(' __opencodeDebug.getAppStatus() - Show app status snapshot');
console.log(' __opencodeDebug.diagnoseSessionDirectory(sessionId?) - Show how the session directory is resolved');
console.log(' __opencodeDebug.getRecentSendFailures() - List prompt sends that were rejected and rolled back');
console.log(' __opencodeDebug.checkLastMessage() - Check if last message is problematic');
console.log(' __opencodeDebug.findEmptyMessages() - Find all empty assistant messages');
console.log(' __opencodeDebug.showRetryHelp() - Show instructions for handling empty responses');
+151
View File
@@ -93,6 +93,157 @@ describe('terminal transport', () => {
transport.dispose();
});
test('invalidates URL auth when the current socket closes before opening', async () => {
const socket = new FakeSocket();
let cleared = 0;
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => socket,
clearUrlAuthToken: () => { cleared += 1; },
});
const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} });
await tick();
socket.close();
await tick();
expect(cleared).toBe(1);
unsubscribe();
transport.dispose();
});
test('invalidates URL auth before retrying a pre-open socket error', async () => {
const socket = new FakeSocket();
let cleared = 0;
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => socket,
clearUrlAuthToken: () => { cleared += 1; },
});
const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} });
await tick();
socket.onerror?.();
expect(cleared).toBe(1);
unsubscribe();
transport.dispose();
});
test('does not let a cancelled opening reconnect a replacement subscription', async () => {
const sockets = [new FakeSocket(), new FakeSocket()];
let socketIndex = 0;
const replacementEvents: string[] = [];
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => sockets[socketIndex++]!,
});
const unsubscribeFirst = transport.subscribe('term-1', { onEvent: () => {} });
await tick();
unsubscribeFirst();
const unsubscribeReplacement = transport.subscribe('term-1', {
onEvent: (event) => replacementEvents.push(event.type),
});
await tick();
sockets[1]?.open();
await tick();
expect(replacementEvents).not.toContain('reconnecting');
unsubscribeReplacement();
transport.dispose();
});
test('starts a fresh reconnect sequence after every terminal has detached', async () => {
const firstEvents: number[] = [];
const replacementEvents: number[] = [];
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => { throw new Error('offline'); },
});
const unsubscribeFirst = transport.subscribe('term-1', {
onEvent: (event) => {
if (event.type === 'reconnecting' && typeof event.attempt === 'number') firstEvents.push(event.attempt);
},
});
await tick();
await tick();
expect(firstEvents).toEqual([1]);
unsubscribeFirst();
const unsubscribeReplacement = transport.subscribe('term-2', {
onEvent: (event) => {
if (event.type === 'reconnecting' && typeof event.attempt === 'number') replacementEvents.push(event.attempt);
},
});
await tick();
await tick();
expect(replacementEvents).toEqual([1]);
unsubscribeReplacement();
transport.dispose();
});
test('waits a minute before reconnecting while hidden', async () => {
const originalSetTimeout = globalThis.setTimeout;
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const delays: number[] = [];
let transport: TerminalTransport | null = null;
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
visibilityState: 'hidden',
addEventListener: () => {},
removeEventListener: () => {},
},
});
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
delays.push(Number(timeout ?? 0));
if (timeout === 0) return originalSetTimeout(handler, 0, ...args);
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout;
try {
transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => { throw new Error('offline'); },
});
transport.subscribe('term-1', { onEvent: () => {} });
await tick();
await tick();
expect(delays).toContain(60_000);
} finally {
transport?.dispose();
globalThis.setTimeout = originalSetTimeout;
if (originalDocument) Object.defineProperty(globalThis, 'document', originalDocument);
else delete (globalThis as { document?: unknown }).document;
}
});
test('attaches a remaining same-terminal subscriber after the first one leaves', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
const unsubscribeOther = transport.subscribe('term-other', { onEvent: () => {} });
await tick();
socket.open();
await tick();
const unsubscribeFirst = transport.subscribe('term-1', { onEvent: () => {} });
const unsubscribeRemaining = transport.subscribe('term-1', { onEvent: () => {} });
unsubscribeFirst();
await tick();
expect(socket.sent.filter((message) => message.t === 'attach' && message.s === 'term-1')).toHaveLength(1);
unsubscribeRemaining();
unsubscribeOther();
transport.dispose();
});
test('releases replay projections when the last subscriber detaches', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
+79 -50
View File
@@ -3,7 +3,7 @@ import { openRuntimeWebSocket } from './relay/runtime-socket';
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
import { refreshRuntimeUrlAuthToken } from './runtime-auth';
import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth';
import { isTerminalShell } from './terminalShell';
type Message = Record<string, unknown> & { t: string; s?: string; q?: number };
@@ -66,12 +66,12 @@ const trimProjection = (value: string): string => {
type TerminalTransportDependencies = {
refreshAuth: () => Promise<unknown>;
openSocket: () => RelayTunnelWebSocket;
clearUrlAuthToken?: () => void;
};
export class TerminalTransport {
private socket: RelayTunnelWebSocket | null = null;
private opening: Promise<void> | null = null;
private openingGeneration: number | null = null;
private subscribers = new Map<string, Set<Subscriber>>();
private projections = new Map<string, TerminalProjection>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -85,6 +85,7 @@ export class TerminalTransport {
constructor(private readonly dependencies: TerminalTransportDependencies = {
refreshAuth: refreshRuntimeUrlAuthToken,
openSocket: () => openRuntimeWebSocket(getRuntimeUrlResolver().websocket('/api/terminal/ws')),
clearUrlAuthToken: clearRuntimeUrlAuthToken,
}) {}
subscribe(sessionId: string, handlers: TerminalHandlers): () => void {
@@ -100,7 +101,13 @@ export class TerminalTransport {
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
}
const socketWasOpen = this.socket?.readyState === SOCKET_OPEN;
this.ensureConnected().then(() => { if (first && socketWasOpen && set.has(subscriber)) this.send({ t: 'attach', v: 3, s: sessionId }); }).catch((error) => {
this.ensureConnected().then(() => {
const current = this.subscribers.get(sessionId);
if (first && socketWasOpen && current === set && current.size > 0) {
this.send({ t: 'attach', v: 3, s: sessionId });
}
}).catch((error) => {
if (!set.has(subscriber)) return;
handlers.onError?.(error, false);
this.scheduleReconnect();
});
@@ -114,6 +121,7 @@ export class TerminalTransport {
}
if (this.subscribers.size === 0) {
this.cancelReconnect();
this.failures = 0;
if (this.socket?.readyState === SOCKET_OPEN) {
// Healthy socket: hold it briefly so a tab switch can reattach to it.
this.scheduleIdleClose();
@@ -121,6 +129,7 @@ export class TerminalTransport {
}
// Nothing to reuse, so abandon any dial that is still in flight.
this.generation += 1;
this.opening = null;
this.closeSocket();
}
};
@@ -138,6 +147,7 @@ export class TerminalTransport {
dispose(): void {
this.disposed = true;
this.generation += 1;
this.opening = null;
this.subscribers.clear();
this.projections.clear();
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
@@ -155,71 +165,89 @@ export class TerminalTransport {
private async ensureConnected(): Promise<void> {
if (this.disposed) throw new Error('Terminal runtime changed');
if (this.socket?.readyState === SOCKET_OPEN) return;
if (this.opening && this.openingGeneration === this.generation) {
if (this.opening) {
await this.opening;
if (this.socket?.readyState === SOCKET_OPEN) return;
return this.ensureConnected();
}
if (this.openingGeneration !== this.generation) {
this.opening = null;
this.openingGeneration = null;
}
const generation = this.generation;
const opening = (async () => {
await this.dependencies.refreshAuth();
if (generation !== this.generation || this.disposed) throw new Error('Terminal runtime changed');
await new Promise<void>((resolve, reject) => {
let settled = false;
let pendingSocket: RelayTunnelWebSocket | null = null;
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error) reject(error);
else resolve();
};
const timeout = setTimeout(() => {
pendingSocket?.close();
finish(new Error('Terminal connection timed out'));
}, 10_000);
try {
const socket = this.dependencies.openSocket();
pendingSocket = socket;
socket.binaryType = 'arraybuffer';
this.socket = socket;
socket.onopen = () => {
if (generation !== this.generation || this.disposed) { socket.close(); finish(new Error('Terminal runtime changed')); return; }
this.failures = 0;
this.send({ t: 'hello', v: 3 });
for (const sessionId of this.subscribers.keys()) this.send({ t: 'attach', v: 3, s: sessionId });
this.startKeepalive();
finish();
let settled = false;
let opened = false;
let authInvalidated = false;
let pendingSocket: RelayTunnelWebSocket | null = null;
const isCurrentSocket = () => (
generation === this.generation &&
!this.disposed &&
pendingSocket !== null &&
this.socket === pendingSocket
);
const invalidatePreOpenAuth = () => {
if (authInvalidated || opened || !isCurrentSocket()) return;
authInvalidated = true;
this.dependencies.clearUrlAuthToken?.();
};
socket.onmessage = (event) => void this.handleMessage(event.data);
socket.onerror = () => {
finish(new Error('Terminal WebSocket failed'));
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error) reject(error);
else resolve();
};
const timeout = setTimeout(() => {
invalidatePreOpenAuth();
pendingSocket?.close();
finish(new Error('Terminal connection timed out'));
}, 10_000);
try {
const socket = this.dependencies.openSocket();
pendingSocket = socket;
socket.binaryType = 'arraybuffer';
this.socket = socket;
socket.onopen = () => {
if (!isCurrentSocket()) { socket.close(); finish(new Error('Terminal runtime changed')); return; }
opened = true;
this.failures = 0;
this.send({ t: 'hello', v: 3 });
for (const sessionId of this.subscribers.keys()) this.send({ t: 'attach', v: 3, s: sessionId });
this.startKeepalive();
finish();
};
socket.onmessage = (event) => void this.handleMessage(event.data);
socket.onerror = () => {
const current = isCurrentSocket();
if (current) invalidatePreOpenAuth();
finish(new Error('Terminal WebSocket failed'));
if (current && this.subscribers.size > 0) this.scheduleReconnect();
};
socket.onclose = () => {
const current = isCurrentSocket();
if (current) {
this.stopKeepalive();
// An upgrade rejected before `open` commonly means the cached
// URL-scoped auth token is stale. Retrying it reaches the 8s
// backoff cap instead of minting a fresh token.
invalidatePreOpenAuth();
}
if (this.socket === socket) this.socket = null;
finish(new Error('Terminal WebSocket closed'));
if (current && this.subscribers.size > 0) this.scheduleReconnect();
};
} catch (error) {
finish(error instanceof Error ? error : new Error('Terminal WebSocket failed'));
if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect();
};
socket.onclose = () => {
if (this.socket === socket) this.socket = null;
this.stopKeepalive();
finish(new Error('Terminal WebSocket closed'));
if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect();
};
} catch (error) {
finish(error instanceof Error ? error : new Error('Terminal WebSocket failed'));
if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect();
}
}
});
})();
this.opening = opening;
this.openingGeneration = generation;
try {
await opening;
} finally {
if (this.opening === opening) {
this.opening = null;
this.openingGeneration = null;
}
}
}
@@ -279,7 +307,7 @@ export class TerminalTransport {
if (this.reconnectTimer || this.disposed || this.subscribers.size === 0) return;
this.failures += 1;
const slow = (typeof document !== 'undefined' && document.visibilityState === 'hidden') || (typeof navigator !== 'undefined' && !navigator.onLine);
const delay = Math.min(500 * 2 ** Math.min(this.failures - 1, 10), slow ? 60_000 : 8_000);
const delay = slow ? 60_000 : Math.min(500 * 2 ** Math.min(this.failures - 1, 10), 8_000);
for (const set of this.subscribers.values()) for (const sub of set) sub.handlers.onEvent({ type: 'reconnecting', attempt: this.failures, maxAttempts: Number.POSITIVE_INFINITY });
const wake = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
@@ -304,6 +332,7 @@ export class TerminalTransport {
this.idleCloseTimer = null;
if (this.disposed || this.subscribers.size > 0) return;
this.generation += 1;
this.opening = null;
this.closeSocket();
}, IDLE_SOCKET_GRACE_MS);
}
+3 -1
View File
@@ -700,7 +700,9 @@ async function performConfigRefresh(options: {
uiRefreshTasks.push(commandsStore.loadCommands().then(() => undefined));
}
if (refreshSkills) {
invalidateSkillsLoadCache(currentDirectory);
// Match loadSkills cache key (active-project-first). Passing client/directory-store
// path here misses the key when those diverge after getRequestDirectory().
invalidateSkillsLoadCache();
uiRefreshTasks.push(skillsStore.loadSkills().then(() => undefined));
uiRefreshTasks.push(skillsCatalogStore.loadCatalog({ refresh: true }).then(() => undefined));
}
@@ -127,6 +127,7 @@ mock.module('./useGlobalSessionsStore', () => ({
}));
mock.module('@/sync/sync-refs', () => ({
getSyncSessionDirectory: () => null,
registerSessionDirectory: (sessionID: string, directory: string) => {
registeredDirectories.push({ sessionID, directory });
},
+19 -17
View File
@@ -15,6 +15,7 @@ import type {
import { invalidateSkillsLoadCache, refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -45,20 +46,21 @@ const getSkillsCatalogCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY;
};
const getCurrentDirectory = (): string | null => {
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
return opencodeDirectory;
}
const getRequestDirectory = (): string | null => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__;
if (store) {
return store.getState().currentDirectory;
const projectsStore = useProjectsStore.getState();
const activeProject = projectsStore.getActiveProject?.();
if (activeProject?.path?.trim()) {
return activeProject.path.trim();
}
} catch {
// ignore
const clientDir = opencodeClient.getDirectory();
if (clientDir?.trim()) {
return clientDir.trim();
}
} catch (err) {
console.warn('[SkillsCatalogStore] Error resolving config directory:', err);
}
return null;
@@ -118,7 +120,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
setSelectedSource: (id) => set({ selectedSourceId: id }),
loadCatalog: async (options) => {
const currentDirectory = getCurrentDirectory();
const currentDirectory = getRequestDirectory();
const cacheKey = getSkillsCatalogCacheKey(currentDirectory);
const now = Date.now();
const loadedAt = skillsCatalogLastLoadedAt.get(cacheKey) ?? 0;
@@ -222,7 +224,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
set({ isLoadingSource: true, lastCatalogError: null });
try {
const currentDirectory = getCurrentDirectory();
const currentDirectory = getRequestDirectory();
const refresh = options?.refresh ? '&refresh=true' : '';
const queryParams = currentDirectory
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
@@ -293,7 +295,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
set({ isLoadingMore: true });
try {
const currentDirectory = getCurrentDirectory();
const currentDirectory = getRequestDirectory();
const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`];
if (currentDirectory) {
parts.push(`directory=${encodeURIComponent(currentDirectory)}`);
@@ -355,7 +357,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
scanRepo: async (request) => {
set({ isScanning: true, lastScanError: null, scanResults: null });
try {
const currentDirectory = getCurrentDirectory();
const currentDirectory = getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await runtimeFetch(`/api/config/skills/scan${queryParams}`, {
@@ -391,7 +393,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
const directoryOverride = typeof options?.directory === 'string' && options.directory.trim().length > 0
? options.directory.trim()
: null;
const currentDirectory = directoryOverride ?? getCurrentDirectory();
const currentDirectory = directoryOverride ?? getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await runtimeFetch(`/api/config/skills/install${queryParams}`, {
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
const activeProjectPath = '/workspace/project-with-agents-skills';
let runtimeFetchCalls: Array<{ url: string; headers?: HeadersInit }> = [];
let runtimeFetchImpl: (url: string, init?: RequestInit) => Promise<Response> = async () => (
new Response(JSON.stringify({ skills: [] }), {
headers: { 'Content-Type': 'application/json' },
})
);
let getDirectoryImpl: () => string | undefined = () => undefined;
const runtimeFetchMock = async (url: string, init?: RequestInit) => {
runtimeFetchCalls.push({ url: String(url), headers: init?.headers });
return runtimeFetchImpl(url, init);
};
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
getDirectory: () => getDirectoryImpl(),
checkHealth: async () => true,
},
}));
mock.module('@/stores/useProjectsStore', () => ({
useProjectsStore: {
getState: () => ({
getActiveProject: () => ({ path: activeProjectPath }),
}),
},
}));
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: runtimeFetchMock,
}));
mock.module('@/lib/background-network', () => ({
runBackgroundNetworkTask: async <T,>(task: () => Promise<T>) => task(),
}));
mock.module('@/lib/configUpdate', () => ({
startConfigUpdate: mock(() => undefined),
finishConfigUpdate: mock(() => undefined),
updateConfigUpdateMessage: mock(() => undefined),
}));
mock.module('@/lib/configSync', () => ({
emitConfigChange: mock(() => undefined),
scopeMatches: mock(() => false),
subscribeToConfigChanges: mock(() => () => undefined),
}));
mock.module('./utils/safeStorage', () => ({
createDeferredSafeJSONStorage: () => ({
getItem: async () => null,
setItem: async () => undefined,
removeItem: async () => undefined,
}),
}));
const { invalidateSkillsLoadCache, useSkillsStore } = await import('./useSkillsStore');
describe('useSkillsStore directory resolution', () => {
beforeEach(() => {
runtimeFetchCalls = [];
getDirectoryImpl = () => undefined;
runtimeFetchImpl = async () => new Response(JSON.stringify({
skills: [{
name: 'repo-local-skill',
path: `${activeProjectPath}/.agents/skills/repo-local-skill/SKILL.md`,
scope: 'project',
source: 'agents',
sources: { md: { description: 'Repository local' } },
}],
}), {
headers: { 'Content-Type': 'application/json' },
});
invalidateSkillsLoadCache(activeProjectPath);
useSkillsStore.setState({
selectedSkillName: null,
skills: [],
isLoading: false,
skillDraft: null,
});
});
test('loadSkills scopes discovery to the active project even when client directory is unset', async () => {
const loaded = await useSkillsStore.getState().loadSkills();
expect(loaded).toBe(true);
expect(runtimeFetchCalls.length).toBe(1);
expect(runtimeFetchCalls[0]?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`);
expect(useSkillsStore.getState().skills).toEqual([{
name: 'repo-local-skill',
path: `${activeProjectPath}/.agents/skills/repo-local-skill/SKILL.md`,
scope: 'project',
source: 'agents',
description: 'Repository local',
group: undefined,
}]);
});
test('invalidateSkillsLoadCache() with no argument clears the active-project cache key used by loadSkills', async () => {
expect(await useSkillsStore.getState().loadSkills()).toBe(true);
expect(runtimeFetchCalls.length).toBe(1);
// Wrong key: client-directory-first null maps to __default__, not the active project.
invalidateSkillsLoadCache(null);
expect(await useSkillsStore.getState().loadSkills()).toBe(true);
expect(runtimeFetchCalls.length).toBe(1);
// Default resolution must match loadSkills (active project first).
invalidateSkillsLoadCache();
expect(await useSkillsStore.getState().loadSkills()).toBe(true);
expect(runtimeFetchCalls.length).toBe(2);
expect(runtimeFetchCalls[1]?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`);
});
});
+66 -41
View File
@@ -10,23 +10,29 @@ import {
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { runtimeFetch } from "@/lib/runtime-fetch";
import { runBackgroundNetworkTask } from "@/lib/background-network";
import { useProjectsStore } from "@/stores/useProjectsStore";
import { opencodeClient } from '@/lib/opencode/client';
const getCurrentDirectory = (): string | null => {
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
return opencodeDirectory;
}
// Prefer the active project path so Settings/Skills discovery matches the
// project selector (and Commands/Agents). Falling back only to the session
// directory misses repository-local `.agents/skills` when the client directory
// is unset or points elsewhere while an active project exists.
const getRequestDirectory = (): string | null => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__;
if (store) {
return store.getState().currentDirectory;
const projectsStore = useProjectsStore.getState();
const activeProject = projectsStore.getActiveProject?.();
if (activeProject?.path?.trim()) {
return activeProject.path.trim();
}
} catch {
// ignore
const clientDir = opencodeClient.getDirectory();
if (clientDir?.trim()) {
return clientDir.trim();
}
} catch (err) {
console.warn('[SkillsStore] Error resolving config directory:', err);
}
return null;
@@ -173,7 +179,7 @@ const getSkillsCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
};
export const invalidateSkillsLoadCache = (directory: string | null = getCurrentDirectory()) => {
export const invalidateSkillsLoadCache = (directory: string | null = getRequestDirectory()) => {
skillsLastLoadedAt.delete(getSkillsCacheKey(directory));
};
@@ -202,8 +208,8 @@ export const useSkillsStore = create<SkillsStore>()(
},
loadSkills: async () => {
const currentDirectory = getCurrentDirectory();
const cacheKey = getSkillsCacheKey(currentDirectory);
const directory = getRequestDirectory();
const cacheKey = getSkillsCacheKey(directory);
const now = Date.now();
const loadedAt = skillsLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedSkills = get().skills.length > 0;
@@ -224,9 +230,12 @@ export const useSkillsStore = create<SkillsStore>()(
for (let attempt = 0; attempt < 3; attempt++) {
try {
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runBackgroundNetworkTask(() => runtimeFetch(`/api/config/skills${queryParams}`, { priority: 'low' }));
const response = await runBackgroundNetworkTask(() => runtimeFetch(`/api/config/skills${queryParams}`, {
priority: 'low',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
}));
if (!response.ok) {
throw new Error(`Failed to list skills: ${response.status}`);
}
@@ -268,10 +277,12 @@ export const useSkillsStore = create<SkillsStore>()(
getSkillDetail: async (name: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`);
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
headers: directory ? { 'x-opencode-directory': directory } : undefined,
});
if (!response.ok) {
return null;
}
@@ -296,12 +307,15 @@ export const useSkillsStore = create<SkillsStore>()(
if (config.source) skillConfig.source = config.source;
if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles;
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(skillConfig)
});
@@ -312,7 +326,7 @@ export const useSkillsStore = create<SkillsStore>()(
}
const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(currentDirectory);
invalidateSkillsLoadCache(directory);
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
@@ -347,12 +361,15 @@ export const useSkillsStore = create<SkillsStore>()(
if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles;
if (config.targetPath !== undefined) skillConfig.targetPath = config.targetPath;
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(skillConfig)
});
@@ -363,7 +380,7 @@ export const useSkillsStore = create<SkillsStore>()(
}
const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(currentDirectory);
invalidateSkillsLoadCache(directory);
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
@@ -435,11 +452,12 @@ export const useSkillsStore = create<SkillsStore>()(
startConfigUpdate("Deleting skill...");
let requiresReload = false;
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE'
method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
});
const payload = await response.json().catch(() => null);
@@ -449,7 +467,7 @@ export const useSkillsStore = create<SkillsStore>()(
}
const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(currentDirectory);
invalidateSkillsLoadCache(directory);
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
@@ -485,11 +503,12 @@ export const useSkillsStore = create<SkillsStore>()(
readSupportingFile: async (skillName: string, filePath: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `&directory=${encodeURIComponent(currentDirectory)}` : '';
const directory = getRequestDirectory();
const queryParams = directory ? `&directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}?${queryParams.slice(1)}`
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}?${queryParams.slice(1)}`,
{ headers: directory ? { 'x-opencode-directory': directory } : undefined },
);
if (!response.ok) {
return null;
@@ -504,14 +523,17 @@ export const useSkillsStore = create<SkillsStore>()(
writeSupportingFile: async (skillName: string, filePath: string, content: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify({ content })
}
);
@@ -524,12 +546,15 @@ export const useSkillsStore = create<SkillsStore>()(
deleteSupportingFile: async (skillName: string, filePath: string) => {
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{ method: 'DELETE' }
{
method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
}
);
return response.ok;
+25
View File
@@ -197,6 +197,30 @@ Directory stores also own session-keyed sidecar notification channels for permis
Message sidecar consumers also filter targeted updates by purpose before notifying React. Suspended live-tail text/reasoning changes do not rebuild visible message records, but structural Task session identity changes bypass suspension so a parent can link a newly created subagent immediately. Assistant-only part changes do not rebuild user input history, and targeted updates that preserve authoritative part buckets do not recheck a session that is already renderable. Message replacements, removed final part buckets, and conservative resets always notify.
## Session directory resolution
`session-directory-resolution.ts` owns the precedence used to answer "which directory does this session belong to". Every send, message fetch, message-queue key, and send-confirmation lookup is routed by that answer, so a wrong value is not a display problem: the prompt is posted against a directory that does not own the session, the request is rejected, and the optimistic message is rolled back with no visible error.
Precedence, highest authority first:
The discriminator is whether the server confirmed the path, not whether the value is local or synced.
| Source | Meaning |
|---|---|
| `authoritative` | The child store that actually holds the session, then its own record |
| `selected` | Server-confirmed directory captured at selection; a guessed one is never passed |
| `attachment` | Worktree attachment recorded by this client; the *requested* path |
| `worktree-metadata` | Worktree captured when the session was created in one; the *requested* path |
| `remembered` | Per-runtime directory persisted across restarts |
Rules:
1. `getSyncSessionDirectory()` is the authoritative session→directory mapping: a session lives in exactly the child store for its directory, whether or not the server populated `session.directory`. `null` means "not indexed yet", never "no directory".
2. `attachment` and `worktreeMetadata` hold the worktree path this client asked for, before the server canonicalized it. They are a hint for a session sync has not indexed yet, never a correction of a confirmed directory — otherwise a stale local path re-creates the very mismatch this precedence exists to prevent.
3. Never persist or rank a guessed directory. `selectSession` may fall back to the active directory to keep routing usable, but that value is not written to runtime memory, not written to the last-active snapshot, and not passed as `selected` — a persisted guess outlives the race that produced it and survives reloads and restarts.
4. Components must not read `currentSessionDirectory` to build request or queue keys; use `getDirectoryForSession()` so every consumer resolves identically.
5. A disagreement between sources is logged once per session, and `__opencodeDebug.diagnoseSessionDirectory()` reports every source in precedence order.
## Session action rules
Session actions live in `session-actions.ts` and are the canonical place for SDK-calling session mutations that affect global session lists.
@@ -207,6 +231,7 @@ Rules:
2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct.
3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls.
4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected.
5. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
Examples of global-store updates performed in `session-actions.ts`:
@@ -42,6 +42,7 @@ mock.module("../session-ui-store", () => ({
}))
mock.module("../sync-refs", () => ({
getSyncSessionDirectory: () => null,
registerSessionDirectory: (sessionID: string, directory: string) => {
registerSessionDirectoryCalls.push({ sessionID, directory })
},
@@ -4,11 +4,16 @@ import { togglePermissionAutoAccept } from "../../components/chat/permissionAuto
const storage = new Map<string, string>()
const createSessionCalls: Array<{ title?: string; directory: string | null; parentID: string | null; metadata?: unknown }> = []
const permissionAutoAcceptCalls: Array<[string, boolean]> = []
let createdSessionDirectory: string | undefined
const getMockCalls = (fn: unknown): unknown[][] => ((fn as { mock?: { calls: unknown[][] } }).mock?.calls ?? [])
mock.module("zustand", () => ({
create: () => (initializer: (set: (patch: unknown | ((state: unknown) => unknown)) => void, get: () => unknown) => Record<string, unknown>) => {
create: () => (initializer: (
set: (patch: unknown | ((state: unknown) => unknown)) => void,
get: () => unknown,
api?: unknown,
) => Record<string, unknown>) => {
let state: Record<string, unknown>
const get = () => state
const set = (patch: unknown | ((current: Record<string, unknown>) => unknown)) => {
@@ -16,7 +21,12 @@ mock.module("zustand", () => ({
state = next && typeof next === "object" ? { ...state, ...(next as Record<string, unknown>) } : state
}
state = initializer(set, get)
state = initializer(set, get, {
setState: set,
getState: get,
getInitialState: get,
subscribe: () => () => undefined,
} as never)
const store = ((selector?: (current: Record<string, unknown>) => unknown) => (
typeof selector === "function" ? selector(state) : state
@@ -53,6 +63,11 @@ const deferredStorage: Storage = {
mock.module("@/stores/utils/safeStorage", () => ({
getDeferredSafeStorage: () => deferredStorage,
createDeferredSafeJSONStorage: () => ({
getItem: async () => null,
setItem: async () => undefined,
removeItem: async () => undefined,
}),
}))
mock.module("@/lib/opencode/client", () => ({
@@ -224,15 +239,18 @@ mock.module("../sync-refs", () => ({
getSyncMessages: () => [],
getSyncParts: () => [],
getAllSyncSessions: () => [],
getSyncSessionDirectory: () => null,
}))
mock.module("../session-actions", () => ({
createSession: mock(async (title: string | undefined, directory: string | null, parentID: string | null, metadata?: unknown) => {
createSessionCalls.push({ title, directory, parentID, metadata })
return { id: "ses_issue_2039", directory }
return { id: "ses_issue_2039", directory: createdSessionDirectory ?? directory }
}),
deleteSession: mock(async () => true),
deleteSessions: mock(async () => ({ deletedIds: [], failedIds: [] })),
archiveSession: mock(async () => true),
archiveSessions: mock(async () => ({ archivedIds: [], failedIds: [] })),
updateSessionTitle: mock(async () => undefined),
shareSession: mock(async () => undefined),
unshareSession: mock(async () => undefined),
@@ -242,6 +260,9 @@ mock.module("../session-actions", () => ({
unrevertSession: mock(async () => undefined),
forkFromMessage: mock(async () => undefined),
fetchMessagesForSession: mock(async () => undefined),
getSessionLastAssistantModel: () => null,
patchSessionMetadata: mock(async () => undefined),
abortCurrentOperation: mock(async () => undefined),
}))
const { materializeOpenDraftSession, useSessionUIStore } = await import("../session-ui-store")
@@ -298,6 +319,7 @@ describe("issue 2039 draft auto-accept", () => {
storage.clear()
createSessionCalls.length = 0
permissionAutoAcceptCalls.length = 0
createdSessionDirectory = undefined
useSessionUIStore.setState({
currentSessionId: null,
@@ -348,4 +370,45 @@ describe("issue 2039 draft auto-accept", () => {
expect(createSessionCalls).toHaveLength(0)
expect(permissionAutoAcceptCalls).toHaveLength(0)
})
test("uses the server-authoritative directory after worktree session creation", async () => {
createdSessionDirectory = "/canonical/worktree"
useSessionUIStore.getState().openNewSessionDraft({
directoryOverride: "/requested/worktree",
})
const result = await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
})
expect(createSessionCalls[0]?.directory).toBe("/requested/worktree")
expect(result?.directory).toBe("/canonical/worktree")
expect(useSessionUIStore.getState().currentSessionDirectory).toBe("/canonical/worktree")
})
test("routes the session by the canonical directory, not the requested worktree path", async () => {
createdSessionDirectory = "/canonical/worktree"
useSessionUIStore.getState().openNewSessionDraft({
directoryOverride: "/requested/worktree",
})
const created = await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
})
const sessionId = created?.sessionId ?? ""
// The worktree attachment still holds the path this client asked for. The
// directory every send, queue key, and confirmation lookup is routed by
// must be the canonical one the server returned.
useSessionUIStore.getState().setWorktreeMetadata(sessionId, {
path: "/requested/worktree",
projectDirectory: "/repo",
branch: "feature",
label: "feature",
})
expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("/canonical/worktree")
})
})
+49
View File
@@ -0,0 +1,49 @@
/**
* Recent prompt-send failures, kept in memory for diagnostics.
*
* A rejected send rolls the optimistic message back and, for transport-level
* failures, the composer stays silent by design. That makes a misrouted or
* refused prompt indistinguishable from "nothing happened" the user has
* nothing to report beyond "it disappeared".
*
* This buffer gives the failure somewhere to live until someone asks for it,
* via the About dialog's diagnostics report or `__opencodeDebug`. It is
* in-memory only: never persisted, never sent anywhere, and dropped on reload.
*/
const MAX_RECORDED_SEND_FAILURES = 20
const MAX_REASON_LENGTH = 200
export type SendFailureRecord = {
at: number
sessionId: string
messageId: string
/** Directory the prompt was routed to — the value under suspicion. */
directory: string | null
/** HTTP status, or null for a transport failure with no response. */
status: number | null
/** Whether the send may still have been accepted server-side. */
ambiguous: boolean
/** Whether a confirmation refetch ran and failed to find the message. */
confirmationChecked: boolean
reason: string
}
const records: SendFailureRecord[] = []
export function recordSendFailure(record: Omit<SendFailureRecord, 'at' | 'reason'> & { reason: string }): void {
records.push({
...record,
reason: record.reason.slice(0, MAX_REASON_LENGTH),
at: Date.now(),
})
if (records.length > MAX_RECORDED_SEND_FAILURES) {
records.splice(0, records.length - MAX_RECORDED_SEND_FAILURES)
}
}
/** Newest first. */
export function getRecentSendFailures(): SendFailureRecord[] {
return [...records].reverse()
}
@@ -245,6 +245,7 @@ mock.module("./session-deletion-cleanup", () => ({
}))
mock.module("./sync-refs", () => ({
getSyncSessionDirectory: () => null,
registerSessionDirectory: (sessionID: string, directory: string) => {
registeredSessionDirectories.push({ sessionID, directory })
},
+22 -1
View File
@@ -13,6 +13,7 @@ import { opencodeClient } from "@/lib/opencode/client"
import { mergeSessionDirectoryMetadata, resolveGlobalSessionDirectory, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { useConfigStore } from "@/stores/useConfigStore"
import { registerSessionDirectory } from "./sync-refs"
import { recordSendFailure } from "./send-failure-log"
import { isSyntheticPart } from "@/lib/messages/synthetic"
import { materializeSessionSnapshots } from "./materialization"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
@@ -1176,7 +1177,9 @@ export async function optimisticSend(input: {
try {
await input.send(messageID)
} catch (error) {
const acceptedRecords = isAmbiguousSendFailure(error)
const status = getErrorStatus(error)
const ambiguousFailure = isAmbiguousSendFailure(error)
const acceptedRecords = ambiguousFailure
? await fetchRecentSendConfirmationRecords(input.sessionId, messageID, targetDirectory)
: null
@@ -1190,6 +1193,24 @@ export async function optimisticSend(input: {
return
}
// The rollback below makes the user's message disappear with no other
// trace, and the composer intentionally stays silent for transport-level
// failures. Record the failure so the About dialog's diagnostics report can
// answer "it disappeared and nothing happened" with an actual cause.
// `reason` is truncated by the recorder: a rejected send echoes the
// provider/OpenCode response body, which this log has no reason to keep.
const failureRecord = {
sessionId: input.sessionId,
messageId: messageID,
directory: targetDirectory ?? null,
status,
ambiguous: ambiguousFailure,
confirmationChecked: ambiguousFailure,
reason: error instanceof Error ? error.message : String(error),
}
recordSendFailure(failureRecord)
console.warn("[session-actions] prompt send rejected; rolling back optimistic message", failureRecord)
// Rollback via optimistic infrastructure
_optimisticRemove({
sessionID: input.sessionId,
@@ -0,0 +1,131 @@
import { describe, expect, test } from 'bun:test';
import {
describeSessionDirectorySources,
resolveSessionDirectoryFromSources,
} from './session-directory-resolution';
const WORKTREE = '/repo/.worktrees/feature';
const MAIN = '/repo';
describe('resolveSessionDirectoryFromSources', () => {
test('authoritative directory beats a selection-time fallback', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
selected: MAIN,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('authoritative');
expect(resolution.conflict).toEqual({ source: 'selected', directory: MAIN });
});
test('authoritative directory beats a directory persisted across restarts', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
remembered: MAIN,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.conflict).toEqual({ source: 'remembered', directory: MAIN });
});
test('the indexed directory outranks a locally requested worktree path', () => {
// attachment/worktreeMetadata hold the path this client asked for, before
// the server canonicalized it. Letting them win would route prompts to a
// directory that no child store owns.
const resolution = resolveSessionDirectoryFromSources({
attachment: '/requested/worktree',
worktreeMetadata: '/requested/worktree',
authoritative: WORKTREE,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('authoritative');
expect(resolution.conflict).toEqual({ source: 'attachment', directory: '/requested/worktree' });
});
test('a worktree attachment is used while the session is not indexed yet', () => {
// A guessed selection is not passed as `selected` at all, so the worktree
// assignment is the best available value during the bootstrap race.
const resolution = resolveSessionDirectoryFromSources({
authoritative: null,
selected: null,
attachment: WORKTREE,
remembered: MAIN,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('attachment');
expect(resolution.conflict).toEqual({ source: 'remembered', directory: MAIN });
});
test('a server-confirmed selection outranks the requested worktree path', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: null,
selected: '/canonical/worktree',
attachment: '/requested/worktree',
worktreeMetadata: '/requested/worktree',
});
expect(resolution.directory).toBe('/canonical/worktree');
expect(resolution.source).toBe('selected');
});
test('falls back to the selection hint while the session is not indexed yet', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: null,
selected: WORKTREE,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('selected');
expect(resolution.conflict).toBeNull();
});
test('agreeing sources report no conflict', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
selected: WORKTREE,
remembered: WORKTREE,
});
expect(resolution.conflict).toBeNull();
});
test('reports the first disagreeing source, not the last', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
selected: MAIN,
remembered: '/somewhere/else',
});
expect(resolution.conflict).toEqual({ source: 'selected', directory: MAIN });
});
test('treats missing and blank values as unknown, never as a directory', () => {
const resolution = resolveSessionDirectoryFromSources({
attachment: null,
worktreeMetadata: ' ',
authoritative: undefined,
selected: '',
});
expect(resolution.directory).toBeNull();
expect(resolution.source).toBe('none');
expect(resolution.conflict).toBeNull();
});
});
describe('describeSessionDirectorySources', () => {
test('lists populated sources in precedence order', () => {
expect(describeSessionDirectorySources({
remembered: MAIN,
authoritative: WORKTREE,
selected: '',
})).toEqual([
{ source: 'authoritative', directory: WORKTREE },
{ source: 'remembered', directory: MAIN },
]);
});
});
@@ -0,0 +1,137 @@
/**
* Session directory resolution precedence.
*
* A session's directory decides which OpenCode project every send, message
* fetch, queue key, and confirmation lookup is routed to. Getting it wrong is
* not a cosmetic problem: the prompt is posted against a directory that does
* not own the session, the send is rejected, and the optimistic message is
* rolled back with no visible error.
*
* The precedence below is deliberate and ordered by authority, not by
* convenience:
*
* The ordering discriminator is **whether the server confirmed the path**, not
* whether the value is local or synced:
*
* 1. `authoritative` the child store that actually holds the session, then
* the session's own record. Server-backed truth for an indexed session.
* 2. `selected` the directory captured when the session was selected, but
* only when it came from a server response (the directory `createSession`
* returned, which may be a canonicalized form of what was requested). A
* selection that fell back to the active directory is a guess and is not
* passed here at all.
* 3. `attachment` / `worktreeMetadata` the worktree this client assigned to
* the session. Both hold the *requested* path, before the server had a
* chance to canonicalize it, so they are a hint for a session sync has not
* indexed yet, never a correction of a confirmed one.
* 4. `remembered` the per-runtime directory persisted across restarts. Last
* resort: it survives reloads, so a value written from a startup fallback
* would otherwise outlive the race that produced it.
*
* Routing a prompt by an unconfirmed path posts it against a directory that
* does not own the session, and the send is rejected. Moves need no exception:
* a session move updates the owning child store before any client-side value.
*/
export type SessionDirectorySource =
| 'authoritative'
| 'selected'
| 'attachment'
| 'worktree-metadata'
| 'remembered'
| 'none'
export type SessionDirectorySources = {
/** Directory of the child store that holds the session, or its own record. */
authoritative?: string | null
/** Server-confirmed directory captured at selection. Never a guessed one. */
selected?: string | null
/** Worktree attachment recorded for this session; the requested path. */
attachment?: string | null
/** Worktree metadata captured when the session was created in a worktree. */
worktreeMetadata?: string | null
/** Directory persisted for this runtime; may outlive the race that wrote it. */
remembered?: string | null
}
export type SessionDirectoryResolution = {
directory: string | null
source: SessionDirectorySource
/**
* Set when a lower-priority source disagrees with the winning one. This is
* the signature of the stale-directory bug: a persisted or selection-time
* fallback pointing at the parent repository while the session lives in a
* worktree.
*/
conflict: { source: SessionDirectorySource; directory: string } | null
}
const RESOLUTION_ORDER: ReadonlyArray<Exclude<SessionDirectorySource, 'none'>> = [
'authoritative',
'selected',
'attachment',
'worktree-metadata',
'remembered',
]
const readSource = (
sources: SessionDirectorySources,
source: Exclude<SessionDirectorySource, 'none'>,
): string | null => {
const value = source === 'attachment'
? sources.attachment
: source === 'worktree-metadata'
? sources.worktreeMetadata
: source === 'authoritative'
? sources.authoritative
: source === 'selected'
? sources.selected
: sources.remembered
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
/**
* Resolve a session directory from every known source, reporting which source
* won and whether a weaker source disagreed.
*
* Callers normalize paths before passing them in; this module only orders
* authority and never rewrites a path.
*/
export const resolveSessionDirectoryFromSources = (
sources: SessionDirectorySources,
): SessionDirectoryResolution => {
let winner: { source: SessionDirectorySource; directory: string } | null = null
let conflict: { source: SessionDirectorySource; directory: string } | null = null
for (const source of RESOLUTION_ORDER) {
const directory = readSource(sources, source)
if (!directory) continue
if (!winner) {
winner = { source, directory }
continue
}
if (!conflict && directory !== winner.directory) {
conflict = { source, directory }
}
}
if (!winner) {
return { directory: null, source: 'none', conflict: null }
}
return { directory: winner.directory, source: winner.source, conflict }
}
/** Every source that carries a value, in precedence order. For diagnostics. */
export const describeSessionDirectorySources = (
sources: SessionDirectorySources,
): Array<{ source: SessionDirectorySource; directory: string }> => {
const described: Array<{ source: SessionDirectorySource; directory: string }> = []
for (const source of RESOLUTION_ORDER) {
const directory = readSource(sources, source)
if (directory) described.push({ source, directory })
}
return described
}
+139 -27
View File
@@ -40,7 +40,13 @@ import {
getSyncMessages,
getSyncParts,
getDirectoryState,
getSyncSessionDirectory,
} from "./sync-refs"
import {
resolveSessionDirectoryFromSources,
type SessionDirectoryResolution,
type SessionDirectorySources,
} from "./session-directory-resolution"
import { markSessionViewed } from "./notification-store"
import { setActiveSession } from "./sync-context"
import {
@@ -73,7 +79,7 @@ import { useSessionWorktreeStore } from "./session-worktree-store"
import { getAttachedSessionDirectory } from "./session-worktree-contract"
import { setSessionOpener } from "./session-navigation"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { clearLastActiveSession, persistLastActiveSession } from "./last-session-cache"
import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache"
import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache"
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
@@ -394,23 +400,108 @@ const getAttachmentForSession = (sessionId: string | null | undefined): SessionW
return useSessionWorktreeStore.getState().getAttachment(sessionId)
}
/**
* Authoritative directory for a session: the child store that holds it, and
* only then the session record's own fields. `null` means "not indexed yet",
* never "no directory" callers must fall back rather than treat it as empty.
*/
const getAuthoritativeSessionDirectory = (sessionId: string): string | null => {
const owningDirectory = getSyncSessionDirectory(sessionId)
if (owningDirectory) return normalizePath(owningDirectory)
const target = getAllSyncSessions().find((s) => s.id === sessionId)
return target ? resolveDirectoryKey(target) : null
}
/**
* Directory remembered for a session in this runtime, plus the one persisted
* across restarts. Exported for diagnostics: a stale persisted directory is the
* hardest source to observe and the one that survives reloads, so a report that
* cannot show it cannot rule it out.
*/
export const getRememberedSessionDirectory = (sessionId: string): {
runtime: string | null
persisted: string | null
} => {
const key = runtimeMemoryKey()
const runtimeMemory = runtimeSessionMemory.get(key)
const persisted = readLastActiveSession(key)
return {
runtime: runtimeMemory?.sessionId === sessionId ? normalizePath(runtimeMemory.directory) : null,
persisted: persisted?.sessionId === sessionId ? normalizePath(persisted.directory) : null,
}
}
/**
* Session whose `currentSessionDirectory` is only the active directory, used
* because the session's own directory was not known at selection time. Such a
* value must never outrank a worktree assignment or reach persistence it is
* a guess, not a selection.
*/
let guessedSelectionSessionId: string | null = null
const collectSessionDirectorySources = (
sessionId: string,
getWtMeta: (id: string) => WorktreeMetadata | undefined,
selected: string | null,
): SessionDirectorySources => ({
authoritative: getAuthoritativeSessionDirectory(sessionId),
selected: sessionId === guessedSelectionSessionId ? null : normalizePath(selected),
attachment: getAttachedSessionDirectory(getAttachmentForSession(sessionId)),
worktreeMetadata: normalizePath(getWtMeta(sessionId)?.path ?? null),
remembered: getRememberedSessionDirectory(sessionId).runtime,
})
/**
* Conflicts already warned about, so a stale directory logs once instead of on
* every keystroke. Keyed by runtime *and* the exact pair of directories: the
* same session ID means a different thing in another runtime, and a conflict
* that reappears after being resolved is news worth logging again. Bounded so
* a long-lived session cannot grow it without limit.
*/
const reportedDirectoryConflicts = new Set<string>()
const MAX_REPORTED_DIRECTORY_CONFLICTS = 200
const reportSessionDirectoryConflict = (
sessionId: string,
resolution: SessionDirectoryResolution,
): void => {
if (!resolution.conflict) return
const conflictKey = JSON.stringify([
runtimeMemoryKey(),
sessionId,
resolution.directory,
resolution.conflict.source,
resolution.conflict.directory,
])
if (reportedDirectoryConflicts.has(conflictKey)) return
if (reportedDirectoryConflicts.size >= MAX_REPORTED_DIRECTORY_CONFLICTS) {
reportedDirectoryConflicts.clear()
}
reportedDirectoryConflicts.add(conflictKey)
console.warn(
"[session-directory] session directory sources disagree; using the higher-authority one. "
+ "Run __opencodeDebug.diagnoseSessionDirectory() for the full picture.",
{
sessionId,
using: resolution.source,
directory: resolution.directory,
conflictingSource: resolution.conflict.source,
conflictingDirectory: resolution.conflict.directory,
},
)
}
const resolveSessionDirectory = (
sessionId: string | null | undefined,
getWtMeta: (id: string) => WorktreeMetadata | undefined,
selected: string | null = null,
): string | null => {
if (!sessionId) return null
const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId))
if (attachmentDirectory) return attachmentDirectory
const metaPath = getWtMeta(sessionId)?.path
if (typeof metaPath === "string" && metaPath.trim().length > 0) return normalizePath(metaPath)
const runtimeMemory = runtimeSessionMemory.get(runtimeMemoryKey())
if (runtimeMemory?.sessionId === sessionId && runtimeMemory.directory) {
return normalizePath(runtimeMemory.directory)
}
const sessions = getAllSyncSessions()
const target = sessions.find((s) => s.id === sessionId)
if (!target) return null
return resolveDirectoryKey(target)
const resolution = resolveSessionDirectoryFromSources(
collectSessionDirectorySources(sessionId, getWtMeta, selected),
)
reportSessionDirectoryConflict(sessionId, resolution)
return resolution.directory
}
const activateConfigForDirectory = async (directory: string | null | undefined): Promise<void> => {
@@ -504,13 +595,18 @@ export async function materializeOpenDraftSession(selection: {
const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
if (!created?.id) throw new Error("Failed to create session")
// The server response is authoritative. It may canonicalize a requested
// worktree path (for example through a symlink or platform path casing).
// Sending with the pre-canonical draft path can target a different
// directory scope than the session that was just created.
const createdDirectory = normalizePath(created.directory ?? draftDirectoryOverride ?? null)
persistDraftTarget({
projectId: draftProjectId,
directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null),
directory: createdDirectory,
})
const draftSyntheticParts = draft.syntheticParts
const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null)
const configState = useConfigStore.getState()
void activateConfigForDirectory(createdDirectory).catch((error) => {
console.warn("Failed to activate directory after creating session:", error)
@@ -604,7 +700,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
(sid) => get().worktreeMetadata.get(sid),
)
const fallbackDir = opencodeClient.getDirectory() ?? directoryState.currentDirectory ?? null
const resolvedDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir ?? fallbackDir
const knownDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir
const resolvedDir = knownDir ?? fallbackDir
// `fallbackDir` is the active directory, not this session's directory. It
// keeps routing usable while the owning directory store bootstraps, but it
// must never be remembered: a persisted guess outlives the race that
// produced it and survives reloads and restarts.
const isGuessedDir = knownDir === null
const projectsState = useProjectsStore.getState()
const sessionProject = resolvedDir
? resolveProjectForSessionDirectory(
@@ -617,12 +719,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// Set the directory together with the session id so chat hooks read the
// same child store that send/SSE events will update during startup races.
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
guessedSelectionSessionId = isGuessedDir && id ? id : null
const rememberedDir = isGuessedDir ? null : resolvedDir ?? null
writeRuntimeSessionMemory(key, { sessionId: id, directory: rememberedDir })
// Keep the last NON-null session per runtime across app restarts (cold
// mobile launches reopen it after the instance reconnects). Going back to
// a draft intentionally does not erase it.
if (id) {
persistLastActiveSession(key, { sessionId: id, directory: resolvedDir ?? null })
persistLastActiveSession(key, { sessionId: id, directory: rememberedDir })
}
// Kick off the message fetch on the same tick, before React commits the
@@ -1560,16 +1664,19 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
},
getDirectoryForSession: (sessionId) => {
if (sessionId === get().currentSessionId && get().currentSessionDirectory) {
return get().currentSessionDirectory
}
const resolved = resolveSessionDirectory(sessionId, (sid) => get().worktreeMetadata.get(sid))
// The selection-time directory participates in resolution, it does not
// short-circuit it. For a worktree session selected before its directory
// store finished bootstrapping, that value is a startup fallback pointing
// at the parent repository; letting it win would route every send, queue
// key, and send-confirmation lookup to a directory that does not own the
// session.
const selected = sessionId === get().currentSessionId ? get().currentSessionDirectory : null
const resolved = resolveSessionDirectory(
sessionId,
(sid) => get().worktreeMetadata.get(sid),
selected,
)
if (resolved) return resolved
const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId))
if (attachmentDirectory) return attachmentDirectory
const sessions = getAllSyncSessions()
const session = sessions.find((s) => s.id === sessionId)
if (session) return resolveDirectoryKey(session)
const globalStore = useGlobalSessionsStore.getState()
const globalSession = [...globalStore.activeSessions, ...globalStore.archivedSessions]
.find((s) => s.id === sessionId)
@@ -1634,6 +1741,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
setSessionDirectory: (sessionId, directory) => {
const normalized = normalizePath(directory)
// Callers set this from a confirmed destination (a completed move, a
// created worktree), so the selection is no longer a guess.
if (sessionId === guessedSelectionSessionId) {
guessedSelectionSessionId = null
}
if (sessionId === get().currentSessionId) {
set({ currentSessionDirectory: normalized })
writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId, directory: normalized })
+20
View File
@@ -17,6 +17,7 @@ const configListeners = new Set<(directory: string, config: Config) => void>()
let cachedSessionManager: ChildStoreManager | null = null
let cachedSessionSlices = new Map<string, State["session"]>()
let cachedSessionsById = new Map<string, State["session"][number]>()
let cachedSessionDirectoryById = new Map<string, string>()
export function setSyncRefs(
_sdk: OpencodeClient,
@@ -29,6 +30,7 @@ export function setSyncRefs(
cachedSessionManager = null
cachedSessionSlices = new Map()
cachedSessionsById = new Map()
cachedSessionDirectoryById = new Map()
}
_directory = directory
if (registerSessionDirectory) {
@@ -103,20 +105,38 @@ export function getAllSyncSessionMap(): ReadonlyMap<string, State["session"][num
const nextSlices = new Map<string, State["session"]>()
const nextSessionsById = new Map<string, State["session"][number]>()
const nextDirectoriesById = new Map<string, string>()
for (const [directory, store] of stores.children) {
const sessions = store.getState().session
nextSlices.set(directory, sessions)
for (const session of sessions) {
if (!session?.id) continue
nextSessionsById.set(session.id, session)
nextDirectoriesById.set(session.id, directory)
}
}
cachedSessionManager = stores
cachedSessionSlices = nextSlices
cachedSessionsById = nextSessionsById
cachedSessionDirectoryById = nextDirectoriesById
return cachedSessionsById
}
/**
* Directory of the child store that actually holds this session.
*
* This is the authoritative sessiondirectory mapping: a session is present in
* exactly the store for the directory it belongs to, regardless of whether the
* server populated `session.directory` on the record itself. Returns `null`
* when no initialized child store contains the session, which means "unknown",
* never "no directory".
*/
export function getSyncSessionDirectory(sessionId: string): string | null {
if (!sessionId) return null
getAllSyncSessionMap()
return cachedSessionDirectoryById.get(sessionId) ?? null
}
/** Read messages for a session from current directory's child store */
export function getSyncMessages(sessionId: string, directory?: string) {
return getDirectoryState(directory)?.message[sessionId] ?? []