feat(terminal): refactor runtime and add mobile workspace (#2280)
Replace the legacy terminal flow with a shared authenticated WebSocket runtime used across web, desktop, relay, and mobile surfaces. - introduce the v3 terminal protocol with scoped attachments, snapshots, ordered output, bounded replay history, reconnects, and explicit lifecycle - harden PTY creation, restart, resize, close, force-kill, idle cleanup, shell selection, login mode, environment sanitization, and appearance sync - add runtime-aware terminal APIs with relay authentication and Electron parity - add a fullscreen mobile terminal workspace with touch scrolling, long-press selection, safe-area controls, quick keys, and Ctrl/Alt input - add terminal selection attachments, preview detection, project actions, shell settings, and localized UI - harden Ghostty rendering, resize recovery, Unicode handling, block characters, line height, and stale-row behavior - remove the obsolete terminal SSE path and update reverse-proxy guidance - expand terminal runtime, transport, input, selection, and store coverage - avoid duplicate web builds when preparing mobile assets in root CI builds
This commit is contained in:
committed by
GitHub
parent
f5b4a267c0
commit
d4a8c4d2e1
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { useInlineCommentDraftStore } from './useInlineCommentDraftStore';
|
||||
|
||||
const selection = {
|
||||
sessionKey: 'session-1',
|
||||
source: 'terminal' as const,
|
||||
fileLabel: 'Terminal 1',
|
||||
startLine: 4,
|
||||
endLine: 5,
|
||||
code: 'first\nsecond',
|
||||
language: 'term-1',
|
||||
text: '',
|
||||
};
|
||||
|
||||
describe('terminal context drafts', () => {
|
||||
afterEach(() => { useInlineCommentDraftStore.setState({ drafts: {} }); });
|
||||
|
||||
test('persists snapshots by chat session and deduplicates identical selections', () => {
|
||||
useInlineCommentDraftStore.getState().addDraft(selection);
|
||||
useInlineCommentDraftStore.getState().addDraft(selection);
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts('session-1');
|
||||
expect(drafts).toHaveLength(1);
|
||||
expect({ ...drafts[0], id: undefined, createdAt: undefined }).toEqual({ ...selection, id: undefined, createdAt: undefined });
|
||||
});
|
||||
|
||||
test('supports individual removal and ordered consume', () => {
|
||||
useInlineCommentDraftStore.getState().addDraft(selection);
|
||||
useInlineCommentDraftStore.getState().addDraft({ ...selection, startLine: 8, endLine: 8, code: 'third' });
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts('session-1');
|
||||
useInlineCommentDraftStore.getState().removeDraft('session-1', drafts[0].id);
|
||||
expect(useInlineCommentDraftStore.getState().consumeDrafts('session-1')).toHaveLength(1);
|
||||
expect(useInlineCommentDraftStore.getState().getDrafts('session-1')).toEqual([]);
|
||||
});
|
||||
|
||||
test('restores consumed drafts after a failed send without duplicating them', () => {
|
||||
useInlineCommentDraftStore.getState().addDraft(selection);
|
||||
const consumed = useInlineCommentDraftStore.getState().consumeDrafts('session-1');
|
||||
useInlineCommentDraftStore.getState().restoreDrafts('session-1', consumed);
|
||||
useInlineCommentDraftStore.getState().restoreDrafts('session-1', consumed);
|
||||
expect(useInlineCommentDraftStore.getState().getDrafts('session-1')).toEqual(consumed);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
|
||||
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation';
|
||||
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation' | 'terminal';
|
||||
|
||||
export interface InlineCommentDraft {
|
||||
id: string;
|
||||
@@ -29,6 +29,7 @@ interface InlineCommentDraftActions {
|
||||
clearDrafts: (sessionKey: string) => void;
|
||||
getDrafts: (sessionKey: string) => InlineCommentDraft[];
|
||||
consumeDrafts: (sessionKey: string) => InlineCommentDraft[];
|
||||
restoreDrafts: (sessionKey: string, drafts: InlineCommentDraft[]) => void;
|
||||
getDraftCount: (sessionKey: string) => number;
|
||||
hasDrafts: (sessionKey: string) => boolean;
|
||||
}
|
||||
@@ -36,7 +37,7 @@ interface InlineCommentDraftActions {
|
||||
type InlineCommentDraftStore = InlineCommentDraftState & InlineCommentDraftActions;
|
||||
|
||||
const isValidSource = (value: unknown): value is InlineCommentSource =>
|
||||
value === 'diff' || value === 'plan' || value === 'file' || value === 'preview-console' || value === 'preview-annotation';
|
||||
value === 'diff' || value === 'plan' || value === 'file' || value === 'preview-console' || value === 'preview-annotation' || value === 'terminal';
|
||||
|
||||
const isValidSide = (value: unknown): value is 'original' | 'modified' =>
|
||||
value === 'original' || value === 'modified';
|
||||
@@ -62,6 +63,8 @@ const sanitizeDraft = (input: unknown): InlineCommentDraft | null => {
|
||||
? draft.id
|
||||
: `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const code = typeof draft.code === 'string' ? draft.code : '';
|
||||
if (draft.source === 'terminal' && !code.trim()) return null;
|
||||
return {
|
||||
id,
|
||||
sessionKey: draft.sessionKey,
|
||||
@@ -70,7 +73,7 @@ const sanitizeDraft = (input: unknown): InlineCommentDraft | null => {
|
||||
startLine,
|
||||
endLine,
|
||||
side: isValidSide(draft.side) ? draft.side : undefined,
|
||||
code: typeof draft.code === 'string' ? draft.code : '',
|
||||
code,
|
||||
language: typeof draft.language === 'string' ? draft.language : 'text',
|
||||
text: typeof draft.text === 'string' ? draft.text : '',
|
||||
createdAt: Number.isFinite(draft.createdAt) ? Number(draft.createdAt) : Date.now(),
|
||||
@@ -106,6 +109,7 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
|
||||
drafts: {},
|
||||
|
||||
addDraft: (draft) => {
|
||||
if (draft.source === 'terminal' && !draft.code.trim()) return;
|
||||
const id = `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const newDraft: InlineCommentDraft = {
|
||||
...draft,
|
||||
@@ -115,6 +119,9 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
|
||||
|
||||
set((state) => {
|
||||
const currentDrafts = state.drafts[draft.sessionKey] ?? [];
|
||||
if (draft.source === 'terminal' && currentDrafts.some((current) => current.source === 'terminal' && current.fileLabel === draft.fileLabel && current.startLine === draft.startLine && current.endLine === draft.endLine && current.code === draft.code)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
@@ -197,6 +204,22 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
|
||||
return sortedDrafts;
|
||||
},
|
||||
|
||||
restoreDrafts: (sessionKey, drafts) => {
|
||||
if (drafts.length === 0) return;
|
||||
set((state) => {
|
||||
const current = state.drafts[sessionKey] ?? [];
|
||||
const currentIds = new Set(current.map((draft) => draft.id));
|
||||
const restored = drafts.filter((draft) => draft.sessionKey === sessionKey && !currentIds.has(draft.id));
|
||||
if (restored.length === 0) return state;
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[sessionKey]: [...restored, ...current].sort((a, b) => a.createdAt - b.createdAt),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
getDraftCount: (sessionKey) => {
|
||||
return (get().drafts[sessionKey] ?? []).length;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { useTerminalStore } from './useTerminalStore';
|
||||
|
||||
const setup = () => {
|
||||
useTerminalStore.getState().clearAll();
|
||||
useTerminalStore.getState().ensureDirectory('/repo');
|
||||
return useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0].id;
|
||||
};
|
||||
|
||||
describe('terminal state reconciliation', () => {
|
||||
afterEach(() => useTerminalStore.getState().clearAll());
|
||||
|
||||
test('applies snapshots atomically and deduplicates output by sequence', () => {
|
||||
const tabId = setup();
|
||||
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 4);
|
||||
useTerminalStore.getState().appendToBuffer('/repo', tabId, ' output', 5);
|
||||
useTerminalStore.getState().appendToBuffer('/repo', tabId, ' duplicate', 5);
|
||||
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0];
|
||||
expect(tab.bufferChunks.map((chunk) => chunk.data).join('')).toBe('prompt output');
|
||||
expect(tab.lastSequence).toBe(5);
|
||||
});
|
||||
|
||||
test('keeps raw live bytes separate from replay-safe bytes', () => {
|
||||
const tabId = setup();
|
||||
useTerminalStore.getState().appendToBuffer('/repo', tabId, 'prompt\u001b[6n', 1, 'prompt');
|
||||
const chunk = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0].bufferChunks[0];
|
||||
expect(chunk.data).toBe('prompt\u001b[6n');
|
||||
expect(chunk.replayData).toBe('prompt');
|
||||
});
|
||||
|
||||
test('uses collision-resistant tab identities', () => {
|
||||
const tabId = setup();
|
||||
expect(/^tab-\d+$/.test(tabId)).toBe(false);
|
||||
});
|
||||
|
||||
test('does not let stale snapshots replace newer output', () => {
|
||||
const tabId = setup();
|
||||
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'new', 8);
|
||||
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'stale', 7);
|
||||
expect(useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0].bufferChunks[0].data).toBe('new');
|
||||
});
|
||||
|
||||
test('preserves buffer identity for an identical snapshot', () => {
|
||||
const tabId = setup();
|
||||
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8);
|
||||
const previous = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0].bufferChunks;
|
||||
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8);
|
||||
expect(useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0].bufferChunks).toBe(previous);
|
||||
});
|
||||
|
||||
test('caps multibyte scrollback by UTF-8 bytes', () => {
|
||||
const tabId = setup();
|
||||
useTerminalStore.getState().appendToBuffer('/repo', tabId, '界'.repeat(200_000), 1);
|
||||
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0];
|
||||
expect(tab.bufferLength <= 512 * 1024).toBe(true);
|
||||
expect(new TextEncoder().encode(tab.bufferChunks[0].data).byteLength).toBe(tab.bufferLength);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
import { closeTerminal } from '@/lib/terminalApi';
|
||||
import { getSafeSessionStorage } from '@/stores/utils/safeStorage';
|
||||
|
||||
export interface TerminalChunk {
|
||||
id: number;
|
||||
data: string;
|
||||
replayData?: string;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export type TerminalTabLifecycle = 'idle' | 'running' | 'exited';
|
||||
@@ -19,6 +20,7 @@ export type TerminalTab = {
|
||||
iconKey: string | null;
|
||||
bufferChunks: TerminalChunk[];
|
||||
bufferLength: number;
|
||||
lastSequence: number;
|
||||
isConnecting: boolean;
|
||||
createdAt: number;
|
||||
previewUrl: string | null;
|
||||
@@ -55,13 +57,13 @@ interface TerminalStore {
|
||||
setActiveTab: (directory: string, tabId: string) => void;
|
||||
setTabLabel: (directory: string, tabId: string, label: string) => void;
|
||||
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void;
|
||||
closeTab: (directory: string, tabId: string) => Promise<void>;
|
||||
closeTab: (directory: string, tabId: string) => void;
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
|
||||
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => void;
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string) => void;
|
||||
clearBuffer: (directory: string, tabId: string) => void;
|
||||
replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => void;
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string, sequence?: number, replayData?: string) => void;
|
||||
setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean }) => void;
|
||||
markPreviewAutoOpened: (directory: string, tabId: string) => void;
|
||||
setProjectActionRun: (run: TerminalProjectActionRun) => void;
|
||||
@@ -72,11 +74,28 @@ interface TerminalStore {
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
const TERMINAL_BUFFER_LIMIT = 1_000_000;
|
||||
const TERMINAL_BUFFER_LIMIT = 512 * 1024;
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
const byteLength = (value: string): number => textEncoder.encode(value).byteLength;
|
||||
const trimToBufferLimit = (value: string): string => {
|
||||
const bytes = textEncoder.encode(value);
|
||||
if (bytes.byteLength <= TERMINAL_BUFFER_LIMIT) return value;
|
||||
let start = bytes.byteLength - TERMINAL_BUFFER_LIMIT;
|
||||
while (start < bytes.byteLength && (bytes[start] & 0xc0) === 0x80) start += 1;
|
||||
return textDecoder.decode(bytes.subarray(start));
|
||||
};
|
||||
const TERMINAL_STORE_NAME = 'terminal-store';
|
||||
let hydrationListenerAttached = false;
|
||||
let fallbackTabId = 0;
|
||||
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'iconKey' | 'terminalSessionId' | 'lifecycle' | 'createdAt'>;
|
||||
const createTerminalTabId = (): string => {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') return `tab-${globalThis.crypto.randomUUID()}`;
|
||||
fallbackTabId += 1;
|
||||
return `tab-${Date.now().toString(36)}-${fallbackTabId.toString(36)}`;
|
||||
};
|
||||
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'iconKey' | 'createdAt'>;
|
||||
|
||||
type PersistedDirectoryTerminalState = {
|
||||
tabs: PersistedTerminalTab[];
|
||||
@@ -114,6 +133,7 @@ const createEmptyTab = (id: string, label: string): TerminalTab => ({
|
||||
iconKey: null,
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
lastSequence: -1,
|
||||
isConnecting: false,
|
||||
createdAt: Date.now(),
|
||||
previewUrl: null,
|
||||
@@ -149,7 +169,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
}
|
||||
|
||||
const newSessions = new Map(state.sessions);
|
||||
const tabId = `tab-${state.nextTabId}`;
|
||||
const tabId = createTerminalTabId();
|
||||
const firstTab = createEmptyTab(tabId, 'Terminal');
|
||||
newSessions.set(key, createEmptyDirectoryState(firstTab));
|
||||
|
||||
@@ -177,7 +197,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
return 'tab-invalid';
|
||||
}
|
||||
|
||||
const tabId = `tab-${get().nextTabId}`;
|
||||
const tabId = createTerminalTabId();
|
||||
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
@@ -293,20 +313,8 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
closeTab: async (directory: string, tabId: string) => {
|
||||
closeTab: (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const entry = get().sessions.get(key);
|
||||
const tab = entry?.tabs.find((t) => t.id === tabId);
|
||||
const sessionId = tab?.terminalSessionId ?? null;
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
await closeTerminal(sessionId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
@@ -326,7 +334,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
const runsChanged = Object.keys(nextRuns).length !== Object.keys(state.projectActionRuns).length;
|
||||
|
||||
if (nextTabs.length === 0) {
|
||||
const newTabId = `tab-${state.nextTabId}`;
|
||||
const newTabId = createTerminalTabId();
|
||||
const newTab = createEmptyTab(newTabId, 'Terminal');
|
||||
newSessions.set(key, createEmptyDirectoryState(newTab));
|
||||
return {
|
||||
@@ -381,7 +389,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
terminalSessionId: sessionId,
|
||||
lifecycle: nextLifecycle,
|
||||
isConnecting: false,
|
||||
...(shouldResetBuffer ? { bufferChunks: [], bufferLength: 0 } : {}),
|
||||
...(shouldResetBuffer ? { bufferChunks: [], bufferLength: 0, lastSequence: -1 } : {}),
|
||||
};
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
@@ -433,7 +441,34 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string) => {
|
||||
replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const existing = state.sessions.get(key);
|
||||
if (!existing) return state;
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0 || existing.tabs[idx].lastSequence > sequence) return state;
|
||||
const retainedContent = trimToBufferLimit(content);
|
||||
const bytes = byteLength(retainedContent);
|
||||
const tab = existing.tabs[idx];
|
||||
if (
|
||||
tab.lastSequence === sequence &&
|
||||
tab.bufferLength === bytes &&
|
||||
tab.bufferChunks.map((chunk) => chunk.data).join('') === retainedContent
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const chunkId = state.nextChunkId;
|
||||
const bufferChunks = retainedContent ? [{ id: chunkId, data: retainedContent, byteLength: bytes }] : [];
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = { ...nextTabs[idx], bufferChunks, bufferLength: bytes, lastSequence: sequence };
|
||||
const sessions = new Map(state.sessions);
|
||||
sessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions, nextChunkId: retainedContent ? chunkId + 1 : chunkId };
|
||||
});
|
||||
},
|
||||
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string, sequence?: number, replayData?: string) => {
|
||||
if (!chunk) {
|
||||
return;
|
||||
}
|
||||
@@ -452,18 +487,28 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
if (sequence !== undefined && sequence <= tab.lastSequence) return state;
|
||||
const chunkId = state.nextChunkId;
|
||||
const chunkEntry: TerminalChunk = { id: chunkId, data: chunk };
|
||||
const retainedChunk = trimToBufferLimit(chunk);
|
||||
const retainedReplayData = replayData !== undefined && replayData !== chunk
|
||||
? trimToBufferLimit(replayData)
|
||||
: undefined;
|
||||
const chunkEntry: TerminalChunk = {
|
||||
id: chunkId,
|
||||
data: retainedChunk,
|
||||
...(retainedReplayData !== undefined ? { replayData: retainedReplayData } : {}),
|
||||
byteLength: byteLength(retainedChunk),
|
||||
};
|
||||
|
||||
const bufferChunks = [...tab.bufferChunks, chunkEntry];
|
||||
let bufferLength = tab.bufferLength + chunk.length;
|
||||
let bufferLength = tab.bufferLength + chunkEntry.byteLength;
|
||||
|
||||
while (bufferLength > TERMINAL_BUFFER_LIMIT && bufferChunks.length > 1) {
|
||||
const removed = bufferChunks.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
bufferLength -= removed.data.length;
|
||||
bufferLength -= removed.byteLength;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
@@ -471,6 +516,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
...tab,
|
||||
bufferChunks,
|
||||
bufferLength,
|
||||
lastSequence: sequence ?? tab.lastSequence,
|
||||
};
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
|
||||
@@ -578,31 +624,6 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
clearBuffer: (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...nextTabs[idx],
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
};
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
removeDirectory: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
@@ -631,8 +652,6 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
iconKey: tab.iconKey,
|
||||
terminalSessionId: tab.terminalSessionId,
|
||||
lifecycle: tab.lifecycle,
|
||||
createdAt: tab.createdAt,
|
||||
})),
|
||||
},
|
||||
@@ -663,41 +682,35 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
|
||||
const rawTabs = Array.isArray(rawState.tabs) ? (rawState.tabs as unknown[]) : [];
|
||||
const tabs: TerminalTab[] = [];
|
||||
const migratedTabIds = new Map<string, string>();
|
||||
|
||||
for (const rawTab of rawTabs) {
|
||||
if (!isRecord(rawTab)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = typeof rawTab.id === 'string' ? rawTab.id : null;
|
||||
if (!id) {
|
||||
const persistedId = typeof rawTab.id === 'string' ? rawTab.id : null;
|
||||
if (!persistedId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const num = tabIdNumber(id);
|
||||
const num = tabIdNumber(persistedId);
|
||||
if (num !== null) {
|
||||
maxTabNum = Math.max(maxTabNum, num);
|
||||
}
|
||||
|
||||
const terminalSessionId =
|
||||
typeof rawTab.terminalSessionId === 'string' || rawTab.terminalSessionId === null
|
||||
? (rawTab.terminalSessionId as string | null)
|
||||
: null;
|
||||
const lifecycleRaw = rawTab.lifecycle;
|
||||
const lifecycle =
|
||||
lifecycleRaw === 'idle' || lifecycleRaw === 'running' || lifecycleRaw === 'exited'
|
||||
? lifecycleRaw
|
||||
: (terminalSessionId ? 'running' : 'idle');
|
||||
const id = num === null ? persistedId : createTerminalTabId();
|
||||
migratedTabIds.set(persistedId, id);
|
||||
|
||||
tabs.push({
|
||||
id,
|
||||
label: typeof rawTab.label === 'string' ? rawTab.label : 'Terminal',
|
||||
iconKey: typeof rawTab.iconKey === 'string' ? rawTab.iconKey : null,
|
||||
terminalSessionId,
|
||||
lifecycle,
|
||||
terminalSessionId: null,
|
||||
lifecycle: 'idle',
|
||||
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
lastSequence: -1,
|
||||
isConnecting: false,
|
||||
previewUrl: null,
|
||||
previewAutoOpened: false,
|
||||
@@ -711,11 +724,12 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
|
||||
const activeTabId =
|
||||
typeof rawState.activeTabId === 'string' ? (rawState.activeTabId as string) : null;
|
||||
const activeExists = activeTabId ? tabs.some((t) => t.id === activeTabId) : false;
|
||||
const migratedActiveTabId = activeTabId ? (migratedTabIds.get(activeTabId) ?? activeTabId) : null;
|
||||
const activeExists = migratedActiveTabId ? tabs.some((t) => t.id === migratedActiveTabId) : false;
|
||||
|
||||
sessions.set(directory, {
|
||||
tabs,
|
||||
activeTabId: activeExists ? activeTabId : tabs[0].id,
|
||||
activeTabId: activeExists ? migratedActiveTabId : tabs[0].id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { DraftStarterRef } from '@/lib/draftStarters';
|
||||
import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
|
||||
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import type { TerminalShell } from '@/lib/api/types';
|
||||
|
||||
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
|
||||
export type PendingDiffScope = 'working' | 'staged' | 'turn';
|
||||
@@ -591,6 +592,8 @@ interface UIStore {
|
||||
// Global draft welcome starters; null = unset (use the default built-in set).
|
||||
globalDraftStarters: DraftStarterRef[] | null;
|
||||
terminalFontSize: number;
|
||||
terminalShell: TerminalShell;
|
||||
terminalLoginShells: TerminalShell[];
|
||||
editorFontSize: number;
|
||||
uiFont: UiFontOption;
|
||||
monoFont: MonoFontOption;
|
||||
@@ -748,6 +751,8 @@ interface UIStore {
|
||||
setFontSize: (size: number) => void;
|
||||
setGlobalDraftStarters: (refs: DraftStarterRef[]) => void;
|
||||
setTerminalFontSize: (size: number) => void;
|
||||
setTerminalShell: (shell: TerminalShell) => void;
|
||||
setTerminalLoginShells: (shells: TerminalShell[]) => void;
|
||||
setEditorFontSize: (size: number) => void;
|
||||
setUiFont: (font: UiFontOption) => void;
|
||||
setMonoFont: (font: MonoFontOption) => void;
|
||||
@@ -903,7 +908,9 @@ export const useUIStore = create<UIStore>()(
|
||||
messageLimit: 200,
|
||||
fontSize: 100,
|
||||
globalDraftStarters: null,
|
||||
terminalFontSize: 13,
|
||||
terminalFontSize: 14,
|
||||
terminalShell: 'auto',
|
||||
terminalLoginShells: [],
|
||||
editorFontSize: 13,
|
||||
uiFont: DEFAULT_UI_FONT,
|
||||
monoFont: DEFAULT_MONO_FONT,
|
||||
@@ -1671,6 +1678,14 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ terminalFontSize: clamped });
|
||||
},
|
||||
|
||||
setTerminalShell: (shell) => {
|
||||
set({ terminalShell: shell });
|
||||
},
|
||||
|
||||
setTerminalLoginShells: (shells) => {
|
||||
set({ terminalLoginShells: [...new Set(shells)] });
|
||||
},
|
||||
|
||||
setEditorFontSize: (size) => {
|
||||
const rounded = Math.round(size);
|
||||
const clamped = Math.max(9, Math.min(32, rounded));
|
||||
@@ -2206,13 +2221,18 @@ export const useUIStore = create<UIStore>()(
|
||||
{
|
||||
name: 'ui-store',
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
version: 10,
|
||||
version: 11,
|
||||
migrate: (persistedState, version) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState;
|
||||
}
|
||||
const state = persistedState as Record<string, unknown>;
|
||||
|
||||
// v10 -> v11: move the previous terminal font default forward.
|
||||
if (version < 11 && state.terminalFontSize === 13) {
|
||||
state.terminalFontSize = 14;
|
||||
}
|
||||
|
||||
// v9 -> v10: remove obsolete single-file diff view mode setting
|
||||
if (version < 10) {
|
||||
delete state.diffViewMode;
|
||||
@@ -2343,6 +2363,8 @@ export const useUIStore = create<UIStore>()(
|
||||
fontSize: state.fontSize,
|
||||
globalDraftStarters: state.globalDraftStarters,
|
||||
terminalFontSize: state.terminalFontSize,
|
||||
terminalShell: state.terminalShell,
|
||||
terminalLoginShells: state.terminalLoginShells,
|
||||
editorFontSize: state.editorFontSize,
|
||||
uiFont: state.uiFont,
|
||||
monoFont: state.monoFont,
|
||||
|
||||
Reference in New Issue
Block a user