From 03e14b61cde9eaf30f9e8a622e150ca3924e430d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 5 Sep 2026 14:47:21 +0300 Subject: [PATCH] fix(terminal): key project action state by one canonical directory The terminal store keyed its directories by trimming trailing slashes, while the new sidebar activity indicator and the whole-server session grouping keyed the same folder with the project-action normalizer, which also rewrites backslashes. On Windows that split one project into two namespaces: the sidebar reconciled server sessions under "C:/repo" while the terminal panel and the project actions button worked under "C:\repo", so the running-action indicator never lit up, adopted tabs were duplicated and a stop recorded in one namespace did not guard reconciliation in the other. The project actions button also read the store map directly with a path normalized somewhere else, missing its own directory entry. All terminal directory keys now come from normalizeTerminalDirectory in lib/pathNormalization, and store reads go through getDirectoryState. --- .../layout/ProjectActionsButton.tsx | 9 +++++--- .../sessions/DirectoryActionIndicator.tsx | 4 ++-- .../ui/src/components/views/TerminalView.tsx | 2 +- packages/ui/src/lib/pathNormalization.ts | 7 ++++++ .../ui/src/lib/projectActionTerminal.test.ts | 23 ++++++++++++++++++- packages/ui/src/lib/projectActionTerminal.ts | 13 +++-------- .../ui/src/lib/terminalSessionObserver.ts | 4 ++-- packages/ui/src/stores/DOCUMENTATION.md | 4 ++++ .../ui/src/stores/useTerminalStore.test.ts | 13 +++++++++++ packages/ui/src/stores/useTerminalStore.ts | 9 ++------ 10 files changed, 62 insertions(+), 26 deletions(-) diff --git a/packages/ui/src/components/layout/ProjectActionsButton.tsx b/packages/ui/src/components/layout/ProjectActionsButton.tsx index 4430f3dc..fd611469 100644 --- a/packages/ui/src/components/layout/ProjectActionsButton.tsx +++ b/packages/ui/src/components/layout/ProjectActionsButton.tsx @@ -226,13 +226,16 @@ export const ProjectActionsButton = ({ contextHostDirectoryRef.current = contextHostDirectory; }, [contextHostDirectory]); + // The store owns its directory key form; reading `sessions` directly with a + // project-action-normalized path misses the entry whenever the two spellings + // differ (Windows drive letters and separators). const directoryTerminalState = useTerminalStore((state) => ( - normalizedDirectory ? state.sessions.get(normalizedDirectory) : undefined + normalizedDirectory ? state.getDirectoryState(normalizedDirectory) : undefined )); const projectTerminalState = useTerminalStore((state) => ( normalizedProjectDirectory && normalizedProjectDirectory !== normalizedDirectory - ? state.sessions.get(normalizedProjectDirectory) + ? state.getDirectoryState(normalizedProjectDirectory) : undefined )); @@ -1065,7 +1068,7 @@ export const ProjectActionsButton = ({ const previewRun = previewAction ? projectActionRuns[toProjectActionRunKey(executionDirectoryFor(previewAction), previewAction.id)] : null; const selectedRunPreviewUrl = useTerminalStore((state) => { if (!previewRun) return null; - return state.sessions.get(previewRun.directory)?.tabs.find((tab) => tab.id === previewRun.tabId)?.previewUrl ?? null; + return state.getDirectoryState(previewRun.directory)?.tabs.find((tab) => tab.id === previewRun.tabId)?.previewUrl ?? null; }); if (runtime.isVSCode || (!allowMobile && isMobile) || !stableProjectRef || !normalizedDirectory) { diff --git a/packages/ui/src/components/session/sidebar/sessions/DirectoryActionIndicator.tsx b/packages/ui/src/components/session/sidebar/sessions/DirectoryActionIndicator.tsx index f2021ce7..7018f943 100644 --- a/packages/ui/src/components/session/sidebar/sessions/DirectoryActionIndicator.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/DirectoryActionIndicator.tsx @@ -1,14 +1,14 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { useI18n } from '@/lib/i18n'; -import { normalizeProjectActionDirectory } from '@/lib/projectActions'; +import { normalizeTerminalDirectory } from '@/lib/pathNormalization'; import { ACTIVE_PROJECT_ACTION_LIFECYCLES, useTerminalStore } from '@/stores/useTerminalStore'; import { cn } from '@/lib/utils'; /** A directory-scoped leaf subscription; output chunks do not rerender the indicator. */ export const DirectoryActionIndicator = ({ directory, className }: { directory: string; className?: string }) => { const { t } = useI18n(); - const key = normalizeProjectActionDirectory(directory); + const key = normalizeTerminalDirectory(directory); const state = useTerminalStore(React.useCallback(store => store.sessions.get(key), [key])); const active = state?.tabs.some(tab => tab.purpose.type === 'project-action' && tab.purpose.executionId !== null && ACTIVE_PROJECT_ACTION_LIFECYCLES.has(tab.lifecycle)); diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index a0a997e3..ca666866 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -62,7 +62,7 @@ export const TerminalView: React.FC = ({ visible, directory } const targetDirectory = directory ?? null; const terminalDirectory = targetDirectory || contextDirectory; const hasExplicitTerminalTarget = targetDirectory !== null; - const directoryTerminalState = useTerminalStore((s) => terminalDirectory ? s.sessions.get(terminalDirectory) : undefined); + const directoryTerminalState = useTerminalStore((s) => terminalDirectory ? s.getDirectoryState(terminalDirectory) : undefined); const terminalHydrated = useTerminalStore((s) => s.hasHydrated); const ensureDirectory = useTerminalStore((s) => s.ensureDirectory); const createTab = useTerminalStore((s) => s.createTab); diff --git a/packages/ui/src/lib/pathNormalization.ts b/packages/ui/src/lib/pathNormalization.ts index 987f6b7c..e57d133b 100644 --- a/packages/ui/src/lib/pathNormalization.ts +++ b/packages/ui/src/lib/pathNormalization.ts @@ -27,3 +27,10 @@ export const normalizePath = (value?: string | null): string | null => { const stripped = replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced; return stripped || null; }; + +/** + * The directory key every terminal surface must agree on: the terminal store's + * map keys, the server `cwd` values grouped from a session listing, and the + * sidebar indicators. Empty when the value is not a usable path. + */ +export const normalizeTerminalDirectory = (value: string): string => normalizePath(value) ?? ''; diff --git a/packages/ui/src/lib/projectActionTerminal.test.ts b/packages/ui/src/lib/projectActionTerminal.test.ts index c0159222..9b4db4da 100644 --- a/packages/ui/src/lib/projectActionTerminal.test.ts +++ b/packages/ui/src/lib/projectActionTerminal.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import type { TerminalAPI, TerminalHandlers } from './api/types'; +import type { TerminalAPI, TerminalHandlers, TerminalServerSession } from './api/types'; +import { normalizeTerminalDirectory } from './pathNormalization'; import { createProjectActionTerminalSession, + groupTerminalSessionsByDirectory, normalizeProjectActionCommand, reconcileTerminalSessionAuthority, stopProjectActionTerminalSession, @@ -301,3 +303,22 @@ test('cancelling a local request does not close a run adopted from another clien })).rejects.toThrow('PROJECT_ACTION_RUN_CANCELLED'); expect(closed).toEqual([]); }); + +test('a whole-server listing groups under the same directory keys the terminal store uses', () => { + // The sidebar reconciles by these keys while the terminal panel reconciles by + // the store's own key for the same folder; a Windows path must not split into + // two namespaces (`c:\\repo` from the server, `C:/repo` from the sidebar). + const session = (sessionId: string, cwd: string): TerminalServerSession => ({ + sessionId, cwd, status: 'running', createdAt: 1, mode: 'command', + purpose: { type: 'project-action', actionId: 'dev', executionId: sessionId }, + }); + const grouped = groupTerminalSessionsByDirectory([ + session('a', 'c:\\repo'), + session('b', 'C:/repo/'), + session('c', '/srv/app/'), + ]); + + expect([...grouped.keys()].sort()).toEqual(['/srv/app', 'C:/repo']); + expect(grouped.get('C:/repo')?.map(entry => entry.sessionId)).toEqual(['a', 'b']); + expect(normalizeTerminalDirectory('c:\\repo')).toBe('C:/repo'); +}); diff --git a/packages/ui/src/lib/projectActionTerminal.ts b/packages/ui/src/lib/projectActionTerminal.ts index 478f7ebf..08f060b0 100644 --- a/packages/ui/src/lib/projectActionTerminal.ts +++ b/packages/ui/src/lib/projectActionTerminal.ts @@ -1,17 +1,9 @@ -import { normalizeProjectActionDirectory } from './projectActions'; +import { normalizeTerminalDirectory as normalizeDirectory } from './pathNormalization'; import { getRuntimeKey } from './runtime-switch'; import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types'; type TerminalActionMutationRevisions = ReadonlyMap; -const normalizeDirectory = (dir: string): string => { - let normalized = dir.trim(); - while (normalized.length > 1 && normalized.endsWith('/')) { - normalized = normalized.slice(0, -1); - } - return normalized; -}; - type ProjectActionTerminalCreateOptions = Omit, 'mode' | 'command' | 'sessionId'>; type CreateProjectActionTerminalSessionOptions = { @@ -256,10 +248,11 @@ export const reconcileTerminalSessionAuthority = ( }; +/** Groups a whole-server listing into the directory keys the terminal store uses. */ export const groupTerminalSessionsByDirectory = (sessions: TerminalServerSession[]): Map => { const groups = new Map(); for (const session of sessions) { - const directory = normalizeProjectActionDirectory(session.cwd); + const directory = normalizeDirectory(session.cwd); const group = groups.get(directory); if (group) group.push(session); else groups.set(directory, [session]); diff --git a/packages/ui/src/lib/terminalSessionObserver.ts b/packages/ui/src/lib/terminalSessionObserver.ts index 8fe4676e..732a7f1a 100644 --- a/packages/ui/src/lib/terminalSessionObserver.ts +++ b/packages/ui/src/lib/terminalSessionObserver.ts @@ -1,5 +1,5 @@ import type { TerminalAPI } from './api/types'; -import { normalizeProjectActionDirectory } from './projectActions'; +import { normalizeTerminalDirectory } from './pathNormalization'; import { groupTerminalSessionsByDirectory, reconcileTerminalSessionAuthority } from './projectActionTerminal'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from './runtime-switch'; @@ -19,7 +19,7 @@ export const observeTerminalSessions = ( listener: Listener, ): (() => void) => { if (!terminal.listSessions) return () => {}; - const key = normalizeProjectActionDirectory(directory); + const key = normalizeTerminalDirectory(directory); let observation = observations.get(terminal); if (!observation) { const scopes = new Map(); diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 3b0ffdce..4f8abcc2 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -115,6 +115,10 @@ run monitor, and made Zustand persist rewrite the session-storage snapshot per c Invariants to preserve when editing: +- Directory keys come from `normalizeTerminalDirectory` (`lib/pathNormalization.ts`) and + nothing else. Server `cwd` strings, sidebar project paths and the panel's own directory + all pass through it, so a folder has exactly one entry on every platform. Read `sessions` + through `getDirectoryState`, never by indexing the map with a path normalized elsewhere. - Output actions (`appendToBuffer`, `replaceBuffer`) must leave `sessions` referentially unchanged; only `buffers` and `nextChunkId` may change. - Buffer entries are owned by their tab. `closeTab`, `removeDirectory`, `clearAll`, and diff --git a/packages/ui/src/stores/useTerminalStore.test.ts b/packages/ui/src/stores/useTerminalStore.test.ts index 0d663502..bcbaf37e 100644 --- a/packages/ui/src/stores/useTerminalStore.test.ts +++ b/packages/ui/src/stores/useTerminalStore.test.ts @@ -602,3 +602,16 @@ test('a listing started before closing an action cannot resurrect its tab', () = store.reconcileServerSessions('/repo', [session], { startedActionMutationRevisions }); expect(store.getDirectoryState('/repo')?.tabs.some(tab => tab.purpose.type === 'project-action')).toBe(false); }); + +test('a Windows directory keys one store entry however the caller spells it', () => { + useTerminalStore.getState().clearAll(); + const store = useTerminalStore.getState(); + const session: TerminalServerSession = { sessionId: 'run', cwd: 'c:\\repo', status: 'running', createdAt: 1, purpose: { type: 'project-action', actionId: 'dev', executionId: 'run' } }; + // The sidebar reconciles from the server's `cwd`; the terminal panel and the + // project actions button pass the directory they were handed. + store.reconcileServerSessions('c:\\repo', [session]); + + expect(useTerminalStore.getState().sessions.size).toBe(1); + expect(directoryMayHaveActiveProjectAction(store.getDirectoryState('C:/repo/'))).toBe(true); + expect(store.getDirectoryState('C:\\repo')?.tabs).toHaveLength(1); +}); diff --git a/packages/ui/src/stores/useTerminalStore.ts b/packages/ui/src/stores/useTerminalStore.ts index 4a7115a7..6e5bd305 100644 --- a/packages/ui/src/stores/useTerminalStore.ts +++ b/packages/ui/src/stores/useTerminalStore.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { getSafeSessionStorage } from '@/stores/utils/safeStorage'; import type { TerminalServerSession } from '@/lib/api/types'; +import { normalizeTerminalDirectory } from '@/lib/pathNormalization'; export interface TerminalChunk { id: number; @@ -228,13 +229,7 @@ const tabIdNumber = (tabId: string): number | null => { return Number.isFinite(num) ? num : null; }; -function normalizeDirectory(dir: string): string { - let normalized = dir.trim(); - while (normalized.length > 1 && normalized.endsWith('/')) { - normalized = normalized.slice(0, -1); - } - return normalized; -} +const normalizeDirectory = normalizeTerminalDirectory; const actionMutationRevisionKey = (directory: string, actionId: string): string => `${directory}\u0000${actionId}`;