diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 7e13a7a9f4..7efb452eaf 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -361,6 +361,27 @@ } } + &__inline-item-worktree-badge { + flex: 0 0 auto; + // Icon-only circle sized like the neighbouring background-subagent badge, so + // the marker reads at a normal icon size without a label beside the title. + display: inline-grid; + place-items: center; + inline-size: var(--openbitfun-control-icon-size-md); + block-size: var(--openbitfun-control-icon-size-md); + border: 1px dashed var(--openbitfun-color-border-subtle); + border-radius: 999px; + background: var(--openbitfun-color-action-quiet-hover); + color: var(--openbitfun-color-content-secondary); + // Kill the inline strut so the icon stays optically centered in the circle. + line-height: 0; + + .openbitfun-nav-panel__inline-item-worktree-icon { + inline-size: var(--openbitfun-control-icon-size-xs); + block-size: var(--openbitfun-control-icon-size-xs); + } + } + &__inline-item-background-subagent-badge { flex: 0 0 auto; position: relative; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index fe3f6b66ea..ab2de66d8f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -1,5 +1,5 @@ import { useDeviceDirectory, resolveDeviceName } from '@/infrastructure/account/deviceDirectory'; -import { requireSessionWorkspaceId } from '@/flow_chat/utils/sessionWorkspace'; +import { requireSessionOwningWorkspaceId } from '@/flow_chat/utils/sessionOrdering'; /** * SessionsSection — inline accordion content for the "Sessions" nav item. * @@ -9,7 +9,7 @@ import { requireSessionWorkspaceId } from '@/flow_chat/utils/sessionWorkspace'; import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { subscribeOverlayInteraction, createOverlayPortal, Button, Icon, IconButton, Input, Menu, MenuItem, OverflowText, Tooltip } from '@openbitfun/ui'; -import { Loader2, Archive, ListChecks } from 'lucide-react'; +import { Loader2, Archive, FolderGit2, ListChecks } from 'lucide-react'; import { RetainedMountBoundary } from '@/shared/presence'; import { useI18n } from '@/infrastructure/i18n'; import { flowChatStore } from '../../../../../flow_chat/store/FlowChatStore'; @@ -18,6 +18,8 @@ import type { FlowChatState, Session } from '../../../../../flow_chat/types/flow import { useSceneStore } from '../../../../stores/sceneStore'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createLogger } from '@/shared/utils/logger'; +import { isSamePath } from '@/shared/utils/pathUtils'; +import { isLinkedWorktreeWorkspace } from '@/shared/types/global-state'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { @@ -32,8 +34,10 @@ import { import { recordHistorySessionDiagnosticEvent } from '@/flow_chat/services/historySessionDiagnostics'; import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; import { + isWorktreeIsolatedSession, sessionBelongsToWorkspaceNavRow, } from '@/flow_chat/utils/sessionOrdering'; +import { sessionWorktreeRootPath } from '@/flow_chat/utils/sessionWorktree'; import { compareWorkspaceNavSessions, DEFAULT_WORKSPACE_SESSION_FILTERS, @@ -237,7 +241,7 @@ const SessionsSection: React.FC = ({ const hasActiveSessionFilter = sessionShow !== 'all' || hasWorkspaceSessionFilters(sessionFilters); const showAllWithoutLimit = layout === 'flat' && Boolean(workspaceScopes?.length); const sessionListClassName = `openbitfun-nav-panel__inline-list${layout === 'flat' ? ' is-flat-workspace-view' : ''}`; - const { setActiveWorkspace, currentWorkspace } = useWorkspaceContext(); + const { setActiveWorkspace, openWorkspace, openedWorkspacesList, currentWorkspace } = useWorkspaceContext(); const activeTabId = useSceneStore(s => s.activeTabId); const activeBtwSessionTab = useAgentCanvasStore(state => selectActiveBtwSessionTab(state as any)); const activeBtwSessionData = activeBtwSessionTab?.content.data as @@ -842,16 +846,32 @@ const SessionsSection: React.FC = ({ }); }, [topLevelSessions.length, expandLevel, level2DisplayCount, showAllWithoutLimit]); - const totalTopLevelSessionCount = !hasActiveSessionFilter && !workspaceScopes?.length - ? getEffectiveTopLevelSessionCount( - metadataPageState.totalTopLevelCount, - metadataPageState.syncedTopLevelCount, - allTopLevelSessions.length, - metadataPageState.isLoading, - ) - : topLevelSessions.length; + // A linked worktree stores its sessions in its main workspace's session root, + // so a metadata page loaded for that directory counts the project's sessions + // as well. That total cannot describe this row: the extra rows it counts belong + // to the project, and a "show more" affordance built on it promises rows this + // list can never reveal. Only the rows this workspace owns are counted here. + // Resolve the row's own workspace, not the active one, because a nested row + // renders while another workspace is active. + const sectionWorkspace = workspaceId + ? openedWorkspacesList.find(workspace => workspace.id === workspaceId) ?? null + : null; + const countOnlyOwnedTopLevelSessions = isLinkedWorktreeWorkspace(sectionWorkspace); + + const totalTopLevelSessionCount = + !hasActiveSessionFilter && !workspaceScopes?.length && !countOnlyOwnedTopLevelSessions + ? getEffectiveTopLevelSessionCount( + metadataPageState.totalTopLevelCount, + metadataPageState.syncedTopLevelCount, + allTopLevelSessions.length, + metadataPageState.isLoading, + ) + : topLevelSessions.length; const hasMoreUnloadedSessions = - !hasActiveSessionFilter && !workspaceScopes?.length && allTopLevelSessions.length < totalTopLevelSessionCount; + !hasActiveSessionFilter + && !workspaceScopes?.length + && !countOnlyOwnedTopLevelSessions + && allTopLevelSessions.length < totalTopLevelSessionCount; const expandToggleState = getSessionExpandToggleState(totalTopLevelSessionCount, expandLevel); // The visible label stays short ("Show more") and the remaining count rides in // a trailing `+N` chip; screen readers get the full sentence via aria-label. @@ -1208,7 +1228,7 @@ const SessionsSection: React.FC = ({ { sessionId: session.sessionId, title: resolveSessionTitle(session), - workspaceId: requireSessionWorkspaceId(session), + workspaceId: requireSessionOwningWorkspaceId(session), }, scope ); @@ -1268,6 +1288,36 @@ const SessionsSection: React.FC = ({ [t] ); + /** + * A worktree directory is registered for execution but not opened, so the + * session row is the only place it can be reached from. Opening it as a + * workspace is an explicit user action; if the worktree happens to be open + * already, activating it is the whole effect. + */ + const handleOpenWorktreeWorkspace = useCallback( + async (e: React.MouseEvent, worktreePath: string) => { + e.stopPropagation(); + closeSessionMenu(); + const opened = openedWorkspacesList.find(workspace => + isSamePath(workspace.rootPath ?? '', worktreePath) + ); + try { + if (opened) { + await setActiveWorkspace(opened.id); + return; + } + await openWorkspace(worktreePath); + } catch (err) { + log.error('Failed to open the worktree directory as a workspace', { + worktreePath, + error: err, + }); + notificationService.error(t('nav.sessions.openWorktreeWorkspaceFailed'), { duration: 3000 }); + } + }, + [closeSessionMenu, openWorkspace, openedWorkspacesList, setActiveWorkspace, t] + ); + const handleStartEdit = useCallback( (e: React.MouseEvent, session: Session) => { e.stopPropagation(); @@ -1535,6 +1585,8 @@ const SessionsSection: React.FC = ({ const showAssistantInTooltip = trimmedAssistant.length > 0; const dispatchTarget = session.config.dispatchTarget; const isDispatched = isNonLocalDispatchTarget(dispatchTarget); + const worktreeIsolated = isWorktreeIsolatedSession(session); + const worktreeRootPath = sessionWorktreeRootPath(session) ?? ''; const dispatchTargetLabel = dispatchTarget?.kind === 'ssh' || dispatchTarget?.kind === 'device' ? (dispatchTarget.kind === 'device' ? resolveDeviceName(dispatchTarget.deviceId, dispatchTarget.displayName) : dispatchTarget.displayName) @@ -1573,6 +1625,7 @@ const SessionsSection: React.FC = ({ showAssistantInTooltip || isChildSession || showBackgroundSubagentActivity || + worktreeIsolated || isDispatched; const tooltipContent = showRichTooltip ? (
@@ -1602,6 +1655,11 @@ const SessionsSection: React.FC = ({ })}
) : null} + {worktreeIsolated ? ( +
+ {t('nav.sessions.worktreeTooltip', { path: worktreeRootPath })} +
+ ) : null} {isDispatched ? (
{dispatchPresentation?.summary} @@ -1735,6 +1793,17 @@ const SessionsSection: React.FC = ({ {dispatchPresentation?.badgeLabel} ) : null} + {worktreeIsolated ? ( + // Icon-only marker: the badge sits next to the title, where a + // label competes with it. The tooltip carries the worktree path. + + + + ) : null} {reviewActivityKind ? ( @@ -1925,6 +1994,17 @@ const SessionsSection: React.FC = ({ > {t('nav.sessions.manage')} + {worktreeIsolated && worktreeRootPath ? ( + } + onClick={e => { void handleOpenWorktreeWorkspace(e, worktreeRootPath); }} + data-testid="nav-session-menu-open-worktree-workspace" + data-session-id={session.sessionId} + > + {t('nav.sessions.openWorktreeWorkspace')} + + ) : null} { + it('derives the worktree fact from the shared session ordering helper', () => { + expect(sessionsSection).toMatch( + /import \{[^}]*isWorktreeIsolatedSession[^}]*\} from '@\/flow_chat\/utils\/sessionOrdering'/, + ); + expect(sessionsSection).toContain('const worktreeIsolated = isWorktreeIsolatedSession(session);'); + // The row must never re-derive the fact from the execution target itself. + expect(sessionsSection).not.toContain('session.config.executionTarget?.kind !=='); + }); + + it('resolves the worktree directory the session runs in through the shared helper', () => { + expect(sessionsSection).toMatch( + /import \{ sessionWorktreeRootPath \} from '@\/flow_chat\/utils\/sessionWorktree'/, + ); + expect(sessionsSection).toContain('const worktreeRootPath = sessionWorktreeRootPath(session) ?? \'\';'); + }); + + it('marks the row with an accessible worktree badge', () => { + expect(sessionsSection).toContain('openbitfun-nav-panel__inline-item-worktree-badge'); + expect(sessionsSection).toMatch( + /aria-label=\{t\('nav\.sessions\.worktreeTooltip', \{ path: worktreeRootPath \}\)\}/, + ); + expect(sessionsSectionStyles).toContain('&__inline-item-worktree-badge {'); + }); + + it('keeps the worktree badge icon-only', () => { + // The marker sits beside the title, so it carries no label text: the tooltip + // and the aria-label stay as the accessible explanation. + expect(sessionsSection).not.toContain("t('nav.sessions.worktreeBadge')"); + expect(sessionsSection).toMatch( + /inline-item-worktree-badge"[\s\S]{0,400}?\s*<\/span>/, + ); + expect(sessionsSectionStyles).toMatch( + /&__inline-item-worktree-badge \{[\s\S]*?inline-size: var\(--openbitfun-control-icon-size-md\);/, + ); + // The glyph keeps a normal icon size inside the badge circle. + expect(sessionsSectionStyles).toMatch( + /\.openbitfun-nav-panel__inline-item-worktree-icon \{[\s\S]*?inline-size: var\(--openbitfun-control-icon-size-xs\);/, + ); + }); + + it('counts only the rows a linked worktree owns when sizing the expand toggle', () => { + // A linked worktree shares its main workspace's session directory, so the + // metadata page total counts the project's sessions too and must not drive + // the expand toggle. + expect(sessionsSection).toMatch( + /const sectionWorkspace = workspaceId\s*\n\s*\? openedWorkspacesList\.find\(workspace => workspace\.id === workspaceId\) \?\? null\s*\n\s*: null;/, + ); + expect(sessionsSection).toContain( + 'const countOnlyOwnedTopLevelSessions = isLinkedWorktreeWorkspace(sectionWorkspace);', + ); + expect(sessionsSection).toMatch( + /!hasActiveSessionFilter && !workspaceScopes\?\.length && !countOnlyOwnedTopLevelSessions\s*\n\s*\? getEffectiveTopLevelSessionCount\(/, + ); + expect(sessionsSection).toMatch( + /import \{ isLinkedWorktreeWorkspace \} from '@\/shared\/types\/global-state';/, + ); + }); + + it('explains the worktree execution in the row tooltip and badge title', () => { + const occurrences = sessionsSection.match( + /t\('nav\.sessions\.worktreeTooltip', \{ path: worktreeRootPath \}\)/g, + ); + // Tooltip line and badge title/aria-label both surface the directory. + expect(occurrences?.length ?? 0).toBeGreaterThanOrEqual(3); + expect(sessionsSection).toMatch(/worktreeIsolated \|\|\s*\n?\s*isDispatched/); + }); + + it('offers opening the worktree directory as a workspace only for worktree rows', () => { + expect(sessionsSection).toMatch( + /\{worktreeIsolated && worktreeRootPath \? \(\s*\n\s* { + expect(sessionsSection).toContain("isSamePath(workspace.rootPath ?? '', worktreePath)"); + expect(sessionsSection).toContain('await setActiveWorkspace(opened.id);'); + expect(sessionsSection).toContain('await openWorkspace(worktreePath);'); + expect(sessionsSection).toContain('const { setActiveWorkspace, openWorkspace, openedWorkspacesList, currentWorkspace } = useWorkspaceContext();'); + }); + + it('reports a failure to open instead of failing silently', () => { + expect(sessionsSection).toContain( + "log.error('Failed to open the worktree directory as a workspace'", + ); + expect(sessionsSection).toContain( + "notificationService.error(t('nav.sessions.openWorktreeWorkspaceFailed')", + ); + }); +}); + +describe('worktree row copy', () => { + const locales = ['en-US', 'zh-CN', 'zh-TW'] as const; + + for (const locale of locales) { + it(`keeps the ${locale} copy for the worktree row`, () => { + const catalog = JSON.parse( + read(`../../../../../locales/${locale}/common.json`), + ) as { nav: { sessions: Record } }; + const sessions = catalog.nav.sessions; + + expect(sessions.openWorktreeWorkspace).toBeTruthy(); + expect(sessions.openWorktreeWorkspaceFailed).toBeTruthy(); + // The badge is icon-only, so it keeps no visible label copy. + expect(sessions.worktreeBadge).toBeUndefined(); + // The badge title and the tooltip both interpolate the directory. + expect(sessions.worktreeTooltip).toContain('{{path}}'); + }); + } +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index b0001e0a69..bee1f0fb2a 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -775,7 +775,8 @@ const WorkspaceItem: React.FC = ({ const surfaceId = getActiveSurfaceId(); window.dispatchEvent(new CustomEvent('terminal-create-requested', { detail: { - workingDirectory: workspace.rootPath, + // No explicit cwd: the resolver picks the active session's execution + // root (a worktree session's worktree) and falls back to this root. surfaceId, resourceScope: { surfaceId, diff --git a/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts b/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts index 236dd132ab..7a3443d96e 100644 --- a/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts +++ b/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import type { Session } from '@/flow_chat/types/flow-chat'; +import { isWorktreeIsolatedSession } from '@/flow_chat/utils/sessionOrdering'; export type WorkspaceSessionGrouping = 'grouped' | 'all'; export type WorkspaceSessionOrdering = 'updated' | 'status' | 'created' | 'name'; @@ -130,8 +131,7 @@ export function deriveWorkspaceSessionSource(session: Session): WorkspaceSession } export function deriveWorkspaceSessionWorktree(session: Session): WorkspaceSessionWorktree { - const target = session.config.executionTarget; - return target && target.kind !== 'local' ? 'worktree' : 'main'; + return isWorktreeIsolatedSession(session) ? 'worktree' : 'main'; } const STATUS_ORDER: Record = { diff --git a/src/web-ui/src/app/hooks/sessionTerminalDirectoryWiring.test.ts b/src/web-ui/src/app/hooks/sessionTerminalDirectoryWiring.test.ts new file mode 100644 index 0000000000..92e7e99262 --- /dev/null +++ b/src/web-ui/src/app/hooks/sessionTerminalDirectoryWiring.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +function read(relativePath: string): string { + return readFileSync( + fileURLToPath(new URL(relativePath, import.meta.url)), + 'utf8', + ).replace(/\r\n/g, '\n'); +} + +const shellEntries = read('../scenes/shell/hooks/useShellEntries.ts'); +const terminalSessions = read('../scenes/shell/hooks/useTerminalSessions.ts'); +const actionBridge = read('../scenes/terminal/TerminalActionBridge.tsx'); +const workspaceItem = read('../components/NavPanel/sections/workspaces/WorkspaceItem.tsx'); + +describe('terminal cwd wiring', () => { + it('prefers the session directory over the workspace root', () => { + expect(shellEntries).toContain('const sessionDirectory = useSessionTerminalDirectory(workspaceId);'); + expect(shellEntries).toContain('defaultDirectory: sessionDirectory,'); + expect(terminalSessions).toContain('workspacePath: directory ?? defaultDirectory ?? workspacePath'); + }); + + it('lets an explicit directory win and otherwise follows the session', () => { + expect(actionBridge).toContain('const workingDirectory = requestedDirectory'); + expect(actionBridge).toContain('?? activeSessionTerminalDirectory(target?.id)'); + expect(actionBridge).toContain('workspacePath: workingDirectory,'); + }); + + it('never hard-codes the project root as the terminal cwd', () => { + // The resolver, not the row, decides the cwd for this workspace scope. + expect(workspaceItem).toContain("window.dispatchEvent(new CustomEvent('terminal-create-requested'"); + expect(workspaceItem).not.toContain('workingDirectory: workspace.rootPath'); + }); +}); diff --git a/src/web-ui/src/app/hooks/useSessionTerminalDirectory.test.ts b/src/web-ui/src/app/hooks/useSessionTerminalDirectory.test.ts new file mode 100644 index 0000000000..db7b018277 --- /dev/null +++ b/src/web-ui/src/app/hooks/useSessionTerminalDirectory.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import { + activeSessionTerminalDirectory, + resolveSessionTerminalDirectory, + useSessionTerminalDirectory, +} from './useSessionTerminalDirectory'; + +const PROJECT = { id: 'project-ws', rootPath: '/repo' }; +const WORKTREE = { id: 'worktree-ws', rootPath: '/worktrees/wt-1' }; + +function session(overrides: Partial = {}): Session { + return { + sessionId: 'session-1', + workspaceId: PROJECT.id, + workspacePath: PROJECT.rootPath, + dialogTurns: [], + status: 'active', + createdAt: 0, + lastActiveAt: 0, + error: null, + sessionKind: 'normal', + config: { executionTarget: { kind: 'local', rootPath: PROJECT.rootPath } }, + ...overrides, + }; +} + +/** The backend rebinds an isolated session to the worktree workspace record. */ +function worktreeSession(): Session { + return session({ + workspaceId: WORKTREE.id, + projectWorkspaceId: PROJECT.id, + workspacePath: WORKTREE.rootPath, + config: { + projectWorkspacePath: PROJECT.rootPath, + executionTarget: { + kind: 'managedWorktree', + worktreeId: 'wt-1', + rootPath: WORKTREE.rootPath, + }, + }, + }); +} + +function publish(active: Session | undefined, sessions: Session[] = []): void { + flowChatStore.setState(state => ({ + ...state, + sessions: new Map((active ? [active, ...sessions] : sessions).map(item => [item.sessionId, item])), + activeSessionId: active?.sessionId ?? null, + })); +} + +describe('session terminal directory', () => { + const initial = flowChatStore.getState(); + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + publish(undefined); + }); + afterEach(() => { flowChatStore.setState(() => initial); }); + + it('starts a terminal in the worktree of the selected session', () => { + expect(resolveSessionTerminalDirectory(worktreeSession(), PROJECT.id)).toBe(WORKTREE.rootPath); + }); + + it('keeps the project root for a session that is not worktree-isolated', () => { + expect(resolveSessionTerminalDirectory(session(), PROJECT.id)).toBeUndefined(); + }); + + it('never follows a worktree into another project scope', () => { + expect(resolveSessionTerminalDirectory(worktreeSession(), 'other-ws')).toBeUndefined(); + expect(resolveSessionTerminalDirectory(undefined, PROJECT.id)).toBeUndefined(); + expect(resolveSessionTerminalDirectory(worktreeSession(), undefined)).toBeUndefined(); + }); + + it('reads the active session at call time', () => { + publish(worktreeSession()); + expect(activeSessionTerminalDirectory(PROJECT.id)).toBe(WORKTREE.rootPath); + expect(activeSessionTerminalDirectory(WORKTREE.id)).toBeUndefined(); + publish(session()); + expect(activeSessionTerminalDirectory(PROJECT.id)).toBeUndefined(); + }); + + it('tracks the selected session for a workspace scope', async () => { + const container = document.createElement('div'); + const root = createRoot(container); + const seen: Array = []; + const Probe = ({ workspaceId }: { workspaceId: string }) => { + seen.push(useSessionTerminalDirectory(workspaceId)); + return null; + }; + try { + await act(async () => root.render(createElement(Probe, { workspaceId: PROJECT.id }))); + expect(seen.at(-1)).toBeUndefined(); + await act(async () => publish(worktreeSession())); + expect(seen.at(-1)).toBe(WORKTREE.rootPath); + await act(async () => publish(session())); + expect(seen.at(-1)).toBeUndefined(); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); +}); diff --git a/src/web-ui/src/app/hooks/useSessionTerminalDirectory.ts b/src/web-ui/src/app/hooks/useSessionTerminalDirectory.ts new file mode 100644 index 0000000000..7f37a550c1 --- /dev/null +++ b/src/web-ui/src/app/hooks/useSessionTerminalDirectory.ts @@ -0,0 +1,50 @@ +import { useSyncExternalStore } from 'react'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import { sessionProjectWorkspaceId } from '@/flow_chat/utils/sessionWorkspace'; +import { sessionWorktreeRootPath } from '@/flow_chat/utils/sessionWorktree'; + +/** + * Working directory a terminal must start in when it is created for the + * `workspaceId` scope. + * + * A worktree-isolated session executes in its worktree, so a terminal started + * from that session's project would otherwise run `git`, build, and test + * commands in the main working tree while the agent edits the worktree. + * + * The terminal stays owned by `workspaceId`; only the cwd follows the session. + * `undefined` means the caller keeps the workspace root, which is also the + * answer when the active session belongs to another project (the workspace ID, + * never a path, decides that) or is not worktree-isolated. + */ +export function resolveSessionTerminalDirectory( + session: Session | undefined, + workspaceId: string | undefined, +): string | undefined { + if (!session || !workspaceId) return undefined; + if (sessionProjectWorkspaceId(session) !== workspaceId) return undefined; + return sessionWorktreeRootPath(session); +} + +/** + * Resolve the directory against the current store state. Action handlers use + * this because they resolve their target workspace at event time. + */ +export function activeSessionTerminalDirectory(workspaceId: string | undefined): string | undefined { + const state = flowChatStore.getState(); + return resolveSessionTerminalDirectory( + state.activeSessionId ? state.sessions.get(state.activeSessionId) : undefined, + workspaceId, + ); +} + +const subscribeFlowChatStore = (listener: () => void) => flowChatStore.subscribe(listener); + +/** Terminal default cwd for `workspaceId`, tracking the active session. */ +export function useSessionTerminalDirectory(workspaceId: string | undefined): string | undefined { + return useSyncExternalStore( + subscribeFlowChatStore, + () => activeSessionTerminalDirectory(workspaceId), + () => undefined, + ); +} diff --git a/src/web-ui/src/app/scenes/shell/hooks/useShellEntries.ts b/src/web-ui/src/app/scenes/shell/hooks/useShellEntries.ts index 0b15e95435..0a6ed6f54c 100644 --- a/src/web-ui/src/app/scenes/shell/hooks/useShellEntries.ts +++ b/src/web-ui/src/app/scenes/shell/hooks/useShellEntries.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { useSessionTerminalDirectory } from '@/app/hooks/useSessionTerminalDirectory'; import { openShellSessionTarget } from '@/shared/services/openShellSessionTarget'; import { AGENT_SOURCE, @@ -51,6 +52,10 @@ export function useShellEntries(targetWorkspace?: WorkspaceInfo | null): UseShel [scope.surfaceId, workspaceId], ); const profileKey = scope.key('terminal-profiles', workspace?.id); + // A terminal opened here serves the session that owns this workspace, so it + // must start where that session executes: a worktree session's cwd is the + // worktree, not the project root. + const sessionDirectory = useSessionTerminalDirectory(workspaceId); const [editingState, setEditingTerminal] = useState(null); const editingTerminal = editingState?.key === profileKey ? editingState : null; @@ -84,6 +89,7 @@ export function useShellEntries(targetWorkspace?: WorkspaceInfo | null): UseShel } = useTerminalSessions({ workspaceId: workspace?.id, workspacePath, + defaultDirectory: sessionDirectory, isRemote, currentConnectionId, scope, diff --git a/src/web-ui/src/app/scenes/shell/hooks/useTerminalSessions.ts b/src/web-ui/src/app/scenes/shell/hooks/useTerminalSessions.ts index a9a7b05a1c..85c8eb2ccc 100644 --- a/src/web-ui/src/app/scenes/shell/hooks/useTerminalSessions.ts +++ b/src/web-ui/src/app/scenes/shell/hooks/useTerminalSessions.ts @@ -12,6 +12,8 @@ import { isSessionRunning, type ShellEntry } from './shellEntryTypes'; interface UseTerminalSessionsOptions { workspaceId?: string; workspacePath?: string; + /** cwd of a terminal created without an explicit directory. */ + defaultDirectory?: string; isRemote: boolean; currentConnectionId: string | null; scope: SurfaceScope; @@ -27,7 +29,7 @@ interface SessionSnapshot { const snapshots = new Map(); export function useTerminalSessions(options: UseTerminalSessionsOptions) { - const { workspaceId, workspacePath, isRemote, currentConnectionId, scope, savedSessionIds } = options; + const { workspaceId, workspacePath, defaultDirectory, isRemote, currentConnectionId, scope, savedSessionIds } = options; const key = scope.key('workspace-terminals', workspaceId); const activation = useMemo(() => ({ key, scope }), [key, scope]); const currentActivation = useRef(activation); @@ -147,13 +149,13 @@ export function useTerminalSessions(options: UseTerminalSessionsOptions) { assertCurrent(); const session = await createManualTerminalSession({ workspaceId: workspaceId!, - workspacePath: directory ?? workspacePath, shellType, shellId, + workspacePath: directory ?? defaultDirectory ?? workspacePath, shellType, shellId, }); assertCurrent(); await refreshSessions(); assertCurrent(); return session; - }, [assertCurrent, refreshSessions, workspacePath, workspaceId]); + }, [assertCurrent, defaultDirectory, refreshSessions, workspacePath, workspaceId]); const stopEntrySession = useCallback(async (entry: ShellEntry) => { if (entry.isRunning) await closeSessionIfPresent(entry.sessionId); }, [closeSessionIfPresent]); diff --git a/src/web-ui/src/app/scenes/terminal/TerminalActionBridge.test.tsx b/src/web-ui/src/app/scenes/terminal/TerminalActionBridge.test.tsx index f8cd09b98b..6d41f67323 100644 --- a/src/web-ui/src/app/scenes/terminal/TerminalActionBridge.test.tsx +++ b/src/web-ui/src/app/scenes/terminal/TerminalActionBridge.test.tsx @@ -6,6 +6,8 @@ import { WorkspaceContext, type WorkspaceContextValue } from '@/infrastructure/c import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import type { WorkspaceInfo } from '@/shared/types'; import { useNavSceneStore } from '@/app/stores/navSceneStore'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { Session } from '@/flow_chat/types/flow-chat'; import { TerminalActionBridge } from './TerminalActionBridge'; const terminal = vi.hoisted(() => ({ create: vi.fn(), open: vi.fn() })); @@ -62,4 +64,46 @@ describe('terminal creation from workspace resources', () => { container.remove(); } }); + + it('starts a terminal in the worktree the active session executes in', async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const project = { id: 'p', rootPath: '/repo', name: 'P', workspaceKind: 'normal' } as WorkspaceInfo; + const state = { ...workspaceManager.getState(), currentWorkspace: project, + activeWorkspaceId: project.id, openedWorkspaces: new Map([[project.id, project]]) }; + vi.spyOn(workspaceManager, 'getState').mockReturnValue(state); + const previousFlowState = flowChatStore.getState(); + const worktreeSession = { + sessionId: 'worktree-session', workspaceId: 'worktree-ws', projectWorkspaceId: project.id, + workspacePath: '/worktrees/wt-1', dialogTurns: [], status: 'active', createdAt: 0, lastActiveAt: 0, + error: null, + config: { + executionTarget: { kind: 'managedWorktree', worktreeId: 'wt-1', rootPath: '/worktrees/wt-1' }, + }, + } as Session; + flowChatStore.setState(current => ({ ...current, + sessions: new Map([[worktreeSession.sessionId, worktreeSession]]), + activeSessionId: worktreeSession.sessionId, + })); + terminal.create.mockResolvedValue({ id: 'terminal-wt', name: 'Shell 1' }); + const container = document.createElement('div'); + const root = createRoot(container); + try { + await act(async () => root.render( + + + , + )); + await act(async () => { + window.dispatchEvent(new CustomEvent('terminal-create-requested', { detail: { surfaceId: 'local' } })); + }); + // The project still owns the terminal; only the cwd is the worktree root. + expect(terminal.create).toHaveBeenCalledExactlyOnceWith({ + workspaceId: project.id, workspacePath: '/worktrees/wt-1', + }); + } finally { + await act(async () => root.unmount()); + container.remove(); + flowChatStore.setState(() => previousFlowState); + } + }); }); diff --git a/src/web-ui/src/app/scenes/terminal/TerminalActionBridge.tsx b/src/web-ui/src/app/scenes/terminal/TerminalActionBridge.tsx index 95f2c4f95e..e8ceecfe20 100644 --- a/src/web-ui/src/app/scenes/terminal/TerminalActionBridge.tsx +++ b/src/web-ui/src/app/scenes/terminal/TerminalActionBridge.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, type FC } from 'react'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { activeSessionTerminalDirectory } from '@/app/hooks/useSessionTerminalDirectory'; import { createManualTerminalSession } from '@/shared/services/createManualTerminalSession'; import { openShellSessionTarget } from '@/shared/services/openShellSessionTarget'; import { createLogger } from '@/shared/utils/logger'; @@ -44,12 +45,18 @@ export const TerminalActionBridge: FC = () => { if (origin && (origin.surfaceId !== scope.surfaceId || !target)) return; const targetPath = target?.rootPath ?? activePath; const remote = target?.workspaceKind === 'remote'; + const requestedDirectory = detail?.workingDirectory; // The requested cwd is an IO operand and must stay inside the target root. - if (detail?.workingDirectory && !isTerminalPathInside(detail.workingDirectory, targetPath, remote)) return; + if (requestedDirectory && !isTerminalPathInside(requestedDirectory, targetPath, remote)) return; if (remote && !target?.connectionId) { notificationService.error(t('nav.resources.unavailable')); return; } + // An explicit directory (file explorer) wins; otherwise the terminal + // follows the active session, which may execute in a worktree. + const workingDirectory = requestedDirectory + ?? activeSessionTerminalDirectory(target?.id) + ?? targetPath; const browseTarget = useNavSceneStore.getState().resourceWorkspace; const isCurrent = () => active && scope.isCurrent() && (origin ? useNavSceneStore.getState().resourceWorkspace === browseTarget @@ -60,7 +67,7 @@ export const TerminalActionBridge: FC = () => { void createManualTerminalSession({ workspaceId: target.id, - workspacePath: detail?.workingDirectory ?? targetPath, + workspacePath: workingDirectory, }) .then((session) => { if (!isCurrent()) return; diff --git a/src/web-ui/src/app/services/openAgentCompanionSession.test.ts b/src/web-ui/src/app/services/openAgentCompanionSession.test.ts index 931b6e8982..077f57fcae 100644 --- a/src/web-ui/src/app/services/openAgentCompanionSession.test.ts +++ b/src/web-ui/src/app/services/openAgentCompanionSession.test.ts @@ -55,10 +55,14 @@ vi.mock('@/infrastructure/services/business/workspaceManager', () => ({ }, })); -vi.mock('@/flow_chat/utils/sessionOrdering', () => ({ - sessionBelongsToWorkspaceNavRow: (...args: unknown[]) => - mocks.sessionBelongsToWorkspaceNavRow(...args), -})); +vi.mock('@/flow_chat/utils/sessionOrdering', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + sessionBelongsToWorkspaceNavRow: (...args: unknown[]) => + mocks.sessionBelongsToWorkspaceNavRow(...args), + }; +}); function createSession(overrides: Partial = {}): Session { return { @@ -192,6 +196,32 @@ describe('openAgentCompanionSession', () => { }); }); + it('jumps to a worktree session through its project, not the opened worktree', async () => { + mocks.sessions.set('tree-session', createSession({ + sessionId: 'tree-session', + workspaceId: 'ws-worktree', + projectWorkspaceId: 'ws-project', + workspacePath: '/projects/main/.worktrees/task', + projectWorkspacePath: '/projects/main', + config: { + executionTarget: { + kind: 'managedWorktree', worktreeId: 'wt-1', rootPath: '/projects/main/.worktrees/task', + }, + }, + })); + mocks.openedWorkspaces.set('ws-project', { id: 'ws-project', rootPath: '/projects/main' }); + mocks.openedWorkspaces.set('ws-worktree', { id: 'ws-worktree', rootPath: '/projects/main/.worktrees/task' }); + mocks.activeWorkspaceId = 'ws-project'; + + await openAgentCompanionSession('tree-session'); + + expect(mocks.openMainSession).toHaveBeenCalledWith('tree-session', { + workspaceId: 'ws-project', + activateWorkspace: undefined, + }); + expect(mocks.setActiveWorkspace).not.toHaveBeenCalled(); + }); + it('returns false when session does not exist', async () => { const opened = await openAgentCompanionSession('nonexistent'); diff --git a/src/web-ui/src/app/services/openAgentCompanionSession.ts b/src/web-ui/src/app/services/openAgentCompanionSession.ts index a98d7b7a4b..4a52bbc84f 100644 --- a/src/web-ui/src/app/services/openAgentCompanionSession.ts +++ b/src/web-ui/src/app/services/openAgentCompanionSession.ts @@ -2,18 +2,8 @@ import { FlowChatStore } from '@/flow_chat/store/FlowChatStore'; import { openBtwSessionInAuxPane } from '@/flow_chat/services/btwSessionPane'; import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; -import { findWorkspaceForSession as resolveWorkspace } from '@/flow_chat/utils/workspaceScope'; +import { resolveSessionSceneWorkspace } from './sessionSceneTarget'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; -import type { Session } from '@/flow_chat/types/flow-chat'; -import type { WorkspaceInfo } from '@/shared/types/global-state'; - -/** - * Resolve the opened workspace that owns this session so the pet-bubble jump - * can activate it — matching the sidebar's workspace-switch behaviour. - */ -function findWorkspaceForSession(session: Session): WorkspaceInfo | null { - return resolveWorkspace(session, workspaceManager.getState().openedWorkspaces.values()) ?? null; -} export async function openAgentCompanionSession(sessionId: string): Promise { const flowChatStore = FlowChatStore.getInstance(); @@ -25,10 +15,13 @@ export async function openAgentCompanionSession(sessionId: string): Promise { }, ); - it('keeps a worktree session in its execution workspace instead of activating its project', async () => { + it('keeps a worktree session active in its project without activating the worktree', async () => { stop?.(); const project = { id: 'project', rootPath: '/projects/main', workspaceKind: WorkspaceKind.Normal, @@ -201,13 +202,16 @@ describe('Session scene resource lifetime with real stores', () => { const active = session('tree-session', { workspaceId: worktree.id, projectWorkspaceId: project.id, workspacePath: worktree.rootPath, projectWorkspacePath: project.rootPath, + config: { + executionTarget: { kind: 'managedWorktree', worktreeId: 'wt-1', rootPath: worktree.rootPath }, + }, }); const workspaceState = { - ...workspaceManager.getState(), currentWorkspace: worktree, activeWorkspaceId: worktree.id, + ...workspaceManager.getState(), currentWorkspace: project, activeWorkspaceId: project.id, openedWorkspaces: new Map([[project.id, project], [worktree.id, worktree]]), }; vi.spyOn(workspaceManager, 'getState').mockImplementation(() => workspaceState); - const setActiveWorkspace = vi.spyOn(workspaceManager, 'setActiveWorkspace').mockResolvedValue(worktree); + const setActiveWorkspace = vi.spyOn(workspaceManager, 'setActiveWorkspace').mockResolvedValue(project); select([active], active.sessionId); stop = startSessionSceneLifecycle(); @@ -215,12 +219,46 @@ describe('Session scene resource lifetime with real stores', () => { expect(useSceneStore.getState().pendingTabId).toBeNull(); expect(useSceneStore.getState().openTabs.map(tab => tab.session?.sessionId)).toEqual([active.sessionId]); + expect(useSceneStore.getState().activeTabId) + .toBe(getSessionSceneTabId(resolveSessionSceneTarget(active, [project], 'local'))); await new Promise(resolve => setTimeout(resolve, 0)); expect(setActiveWorkspace).not.toHaveBeenCalled(); - expect(workspaceState.activeWorkspaceId).toBe(worktree.id); + expect(workspaceState.activeWorkspaceId).toBe(project.id); expect(flowChatStore.getActiveSession()).toBe(active); }); + it('keeps a session created in an open worktree workspace in that workspace', async () => { + stop?.(); + const project = { + id: 'project', rootPath: '/projects/main', workspaceKind: WorkspaceKind.Normal, + } as WorkspaceInfo; + const worktree = { + id: 'worktree', rootPath: '/projects/tree', workspaceKind: WorkspaceKind.Normal, + worktree: { isMain: false, mainRepoPath: project.rootPath, mainWorkspaceId: project.id }, + } as WorkspaceInfo; + const active = session('in-tree-session', { + workspaceId: worktree.id, projectWorkspaceId: project.id, + workspacePath: worktree.rootPath, projectWorkspacePath: project.rootPath, + config: { executionTarget: { kind: 'local', rootPath: worktree.rootPath } }, + }); + const workspaceState = { + ...workspaceManager.getState(), currentWorkspace: worktree, activeWorkspaceId: worktree.id, + openedWorkspaces: new Map([[project.id, project], [worktree.id, worktree]]), + }; + vi.spyOn(workspaceManager, 'getState').mockImplementation(() => workspaceState); + const setActiveWorkspace = vi.spyOn(workspaceManager, 'setActiveWorkspace').mockResolvedValue(worktree); + select([active], active.sessionId); + stop = startSessionSceneLifecycle(); + + useSceneStore.getState().openScene('session'); + + expect(useSceneStore.getState().activeTabId) + .toBe(getSessionSceneTabId(resolveSessionSceneTarget(active, [worktree], 'local'))); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(setActiveWorkspace).not.toHaveBeenCalled(); + expect(workspaceState.activeWorkspaceId).toBe(worktree.id); + }); + it('does not reopen a closed tab when its session is updated in the background', () => { const first = session('a'); select([first], 'a'); diff --git a/src/web-ui/src/app/services/sessionSceneLifecycle.ts b/src/web-ui/src/app/services/sessionSceneLifecycle.ts index 093b345065..7fec6e9a2c 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.ts @@ -6,7 +6,7 @@ import { createLogger } from '@/shared/utils/logger'; import { registerSessionSceneNavigation, useSceneStore } from '../stores/sceneStore'; import { isSessionSceneId, type SessionSceneTarget } from '../components/SceneBar/types'; import { startSessionAuxPaneMemory } from '../scenes/session/sessionAuxPaneMemory'; -import { resolveSessionActivationWorkspace, resolveSessionSceneTarget, resolveSessionSceneWorkspace } from './sessionSceneTarget'; +import { resolveSessionSceneTarget, resolveSessionSceneWorkspace } from './sessionSceneTarget'; const log = createLogger('SessionSceneLifecycle'); @@ -29,11 +29,12 @@ export function startSessionSceneLifecycle(): () => void { if (target.surfaceId !== getActiveSurfaceId() || current()?.sessionId !== target.sessionId) return false; const session = flowChatStore.getActiveSession()!; const state = workspaceManager.getState(); - // The execution workspace owns the session, not the project that persists a - // linked worktree. Comparing the project would make the scene permanently - // inactive there and re-activate it — and the project workspace — on every - // change, which also cancels the selection the user made. - const workspace = resolveSessionActivationWorkspace(session, state.openedWorkspaces.values()); + // A session is active when the workspace it is listed under is active, and + // that is the same owning row its tab key and the navigation list use. A + // worktree session therefore counts as active in its project; comparing its + // execution worktree instead would leave the scene permanently inactive and + // re-activate it on every change, which cancels the selection the user made. + const workspace = resolveSessionSceneWorkspace(session, state.openedWorkspaces.values()); return workspace ? workspace.id === state.activeWorkspaceId : !state.currentWorkspace; }, activate: async (target, isCurrent) => { diff --git a/src/web-ui/src/app/services/sessionSceneTarget.test.ts b/src/web-ui/src/app/services/sessionSceneTarget.test.ts index 6794835cdd..ee12cf90cf 100644 --- a/src/web-ui/src/app/services/sessionSceneTarget.test.ts +++ b/src/web-ui/src/app/services/sessionSceneTarget.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { Session } from '@/flow_chat/types/flow-chat'; import type { WorkspaceInfo } from '@/shared/types'; import { getSessionSceneTabId } from '../components/SceneBar/types'; -import { resolveSessionActivationWorkspace, resolveSessionSceneTarget, resolveSessionSceneWorkspace } from './sessionSceneTarget'; +import { resolveSessionSceneTarget, resolveSessionSceneWorkspace } from './sessionSceneTarget'; const session = (overrides: Partial): Session => ({ sessionId: 'session', title: 'Session', config: {}, dialogTurns: [], @@ -48,28 +48,52 @@ describe('workspace session tab identity', () => { }); }); -describe('session activation workspace identity', () => { +describe('session workspace ownership', () => { const project = { id: 'project', rootPath: '/project' } as WorkspaceInfo; const worktree = { id: 'worktree', rootPath: '/worktrees/task' } as WorkspaceInfo; + const worktreeIsolated = (overrides: Partial = {}) => session({ + workspaceId: worktree.id, projectWorkspaceId: project.id, + workspacePath: worktree.rootPath, projectWorkspacePath: project.rootPath, + config: { executionTarget: { kind: 'managedWorktree', worktreeId: 'wt-1', rootPath: worktree.rootPath } }, + ...overrides, + }); + + it('owns a worktree session through its project for both the tab and activation', () => { + const value = worktreeIsolated(); + + expect(resolveSessionSceneWorkspace(value, [project, worktree])?.id).toBe(project.id); + expect(tabId(value, 'local', [project, worktree])).toBe(tabId(session({ workspaceId: project.id }))); + }); - it('activates the worktree a session is listed under, not its persistence project', () => { + it('owns a session created while browsing the worktree as a workspace by that workspace', () => { const value = session({ workspaceId: worktree.id, projectWorkspaceId: project.id, workspacePath: worktree.rootPath, projectWorkspacePath: project.rootPath, + config: { executionTarget: { kind: 'local', rootPath: worktree.rootPath } }, }); - expect(resolveSessionSceneWorkspace(value, [project, worktree])?.id).toBe(project.id); - expect(resolveSessionActivationWorkspace(value, [project, worktree])?.id).toBe(worktree.id); + expect(resolveSessionSceneWorkspace(value, [project, worktree])?.id).toBe(worktree.id); }); it('falls back to the project only for records that carry no workspace identity', () => { - expect(resolveSessionActivationWorkspace(session({ workspacePath: '/project' }), [project])?.id).toBe(project.id); - expect(resolveSessionActivationWorkspace(session({ projectWorkspaceId: project.id }), [project])?.id).toBe(project.id); - expect(resolveSessionActivationWorkspace(session({ config: { workspaceId: worktree.id } }), [project, worktree])?.id) + expect(resolveSessionSceneWorkspace(session({ workspacePath: '/project' }), [project])?.id).toBe(project.id); + expect(resolveSessionSceneWorkspace(session({ projectWorkspaceId: project.id }), [project])?.id).toBe(project.id); + expect(resolveSessionSceneWorkspace(session({ config: { workspaceId: worktree.id } }), [project, worktree])?.id) .toBe(worktree.id); }); - it('stays unresolved when the listed workspace is not open here', () => { - expect(resolveSessionActivationWorkspace(session({ workspaceId: 'closed' }), [project, worktree])).toBeUndefined(); + it('stays unresolved when the owning workspace is not open here', () => { + expect(resolveSessionSceneWorkspace(session({ workspaceId: 'closed' }), [project, worktree])).toBeUndefined(); + expect(resolveSessionSceneWorkspace(worktreeIsolated({ projectWorkspaceId: 'closed' }), [project, worktree])) + .toBeUndefined(); + }); + + it('keys the tab of a worktree session by its project before that project is open', () => { + const value = worktreeIsolated(); + const projectTab = resolveSessionSceneTarget(session({ workspaceId: project.id }), [], 'local').workspaceKey; + + expect(resolveSessionSceneTarget(value, [], 'local').workspaceKey).toBe(projectTab); + expect(resolveSessionSceneTarget(session({ workspaceId: worktree.id }), [], 'local').workspaceKey) + .not.toBe(projectTab); }); }); diff --git a/src/web-ui/src/app/services/sessionSceneTarget.ts b/src/web-ui/src/app/services/sessionSceneTarget.ts index 10f214b3a4..4148a78214 100644 --- a/src/web-ui/src/app/services/sessionSceneTarget.ts +++ b/src/web-ui/src/app/services/sessionSceneTarget.ts @@ -1,14 +1,22 @@ import type { Session } from '@/flow_chat/types/flow-chat'; +import { sessionOwningWorkspaceId } from '@/flow_chat/utils/sessionOrdering'; import { sessionProjectWorkspacePath } from '@/flow_chat/utils/sessionWorkspace'; import { findWorkspaceForSession } from '@/flow_chat/utils/workspaceScope'; import type { WorkspaceInfo } from '@/shared/types'; import type { SessionSceneTarget } from '../components/SceneBar/types'; -/** Resolve the owning project, including worktree sessions and legacy metadata. */ +/** + * Workspace a session is listed under, and therefore the one navigation must + * activate. It is the owning row, not the execution directory: a session running + * in a linked worktree is listed under its project, so activating the worktree + * would move the surface into a workspace that never shows the session, where + * the workspace bootstrap then replaces the selection. Legacy path resolution + * only serves pre-ID records that carry no workspace identity at all. + */ export function resolveSessionSceneWorkspace(session: Session, workspaces: Iterable) { return findWorkspaceForSession({ ...session, - workspaceId: session.projectWorkspaceId || session.config?.projectWorkspaceId || session.workspaceId || session.config?.workspaceId, + workspaceId: sessionOwningWorkspaceId(session), workspacePath: sessionProjectWorkspacePath(session), remoteConnectionId: session.remoteConnectionId || session.config?.remoteConnectionId, remoteSshHost: session.remoteSshHost || session.config?.remoteSshHost, @@ -20,31 +28,15 @@ export function sessionSceneWorkspaceKey(workspaceId: string): string { return JSON.stringify(['workspace', workspaceId]); } -/** - * Workspace a session is listed under, and therefore the one navigation must - * activate. A session in a linked worktree belongs to its execution workspace; - * its project only owns persistence, so activating the project would move the - * surface into a workspace the session is not listed under, where the workspace - * bootstrap then replaces the selection. Legacy path resolution only serves - * pre-ID records that carry no workspace identity at all. - */ -export function resolveSessionActivationWorkspace(session: Session, workspaces: Iterable) { - return findWorkspaceForSession({ - ...session, - workspaceId: session.workspaceId || session.config?.workspaceId || session.projectWorkspaceId || session.config?.projectWorkspaceId, - workspacePath: sessionProjectWorkspacePath(session), - remoteConnectionId: session.remoteConnectionId || session.config?.remoteConnectionId, - remoteSshHost: session.remoteSshHost || session.config?.remoteSshHost, - }, workspaces); -} - export function resolveSessionSceneTarget( session: Session, workspaces: Iterable, surfaceId: string, ): SessionSceneTarget { const workspace = resolveSessionSceneWorkspace(session, workspaces); - const workspaceId = workspace?.id || session.workspaceId; + // The owning identity keys the tab even before that workspace is open, so a + // tab never migrates from the execution worktree to the project it belongs to. + const workspaceId = workspace?.id ?? sessionOwningWorkspaceId(session); // An unresolved legacy session remains individually addressable. Never group // it with another workspace through a guessed folder key. const workspaceKey = workspaceId diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index fd40f8d636..a426706323 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -118,7 +118,12 @@ import { isSessionWorktreeBindingLocked, } from '../utils/sessionWorktree'; import { chatInputSessionSubscriptionKey } from '../utils/chatInputSessionSubscription'; -import { isLocalWorkspaceSession, sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; +import { + isLocalWorkspaceSession, + sessionProjectWorkspacePath, + sessionWorkspaceId, +} from '../utils/sessionWorkspace'; +import { sessionOwningWorkspaceId } from '../utils/sessionOrdering'; import { findWorkspaceForSession } from '../utils/workspaceScope'; import { isTauriRuntime, isWindowsDesktopRuntime } from '@/infrastructure/runtime'; import { subscribeOverlayInteraction, createOverlayPortal, OverflowText, Tooltip } from '@openbitfun/ui'; @@ -1127,11 +1132,27 @@ export const ChatInput: React.FC = ({ ? findWorkspaceForSession(effectiveTargetSession, openedWorkspaces.values()) : workspace ?? undefined ), [effectiveTargetSession, openedWorkspaces, workspace]); - // Workspace record the input addresses: the targeted session's own record, - // or the context workspace while no session exists yet. An empty string means - // the targeted session has no record; it must not fall back to the context. + // Workspace record the session's own state and configuration are addressed + // with. A worktree-isolated session belongs to the project it was started + // from: its worktree record exists for execution and is usually not an open + // workspace, so a request addressed with that record is rejected outright + // while the owning project resolves to the identical session directory. + const sessionOwningId = effectiveTargetSession + ? sessionOwningWorkspaceId(effectiveTargetSession) + : undefined; + const sessionOwningPath = effectiveTargetSession + ? sessionProjectWorkspacePath(effectiveTargetSession) + : undefined; + // Workspace record the input addresses, or the context workspace while no + // session exists yet. An empty string means the targeted session has no + // record; it must not fall back to the context. const inputWorkspaceId = effectiveTargetSession - ? effectiveTargetSession.workspaceId ?? '' + ? sessionOwningId ?? '' + : contextWorkspace?.id; + // Workspace record of the directory the session actually runs in. Git state + // and dispatch baselines describe that checkout, not the owning project. + const executionWorkspaceId = effectiveTargetSession + ? sessionWorkspaceId(effectiveTargetSession) ?? '' : contextWorkspace?.id; const sessionBoundRemoteConnectionId = ( hasRegisteredWorkspace @@ -1170,15 +1191,15 @@ export const ChatInput: React.FC = ({ // Workspace identity decides whether the session belongs to the current // workspace; a session in a linked worktree still belongs to its owning // project. Path comparison only serves sessions that predate workspace IDs. - const sessionWorkspaceId = hasRegisteredWorkspace + const sessionRecordWorkspaceId = hasRegisteredWorkspace ? undefined : (effectiveTargetSession?.workspaceId || effectiveTargetSession?.config.workspaceId); - const sessionProjectWorkspaceId = hasRegisteredWorkspace + const sessionProjectRecordWorkspaceId = hasRegisteredWorkspace ? undefined : (effectiveTargetSession?.projectWorkspaceId || effectiveTargetSession?.config.projectWorkspaceId); const contextWorkspaceId = hasRegisteredWorkspace ? undefined : workspace?.id; - const sessionUsesDifferentRoot = sessionWorkspaceId && contextWorkspaceId - ? sessionWorkspaceId !== contextWorkspaceId && sessionProjectWorkspaceId !== contextWorkspaceId + const sessionUsesDifferentRoot = sessionRecordWorkspaceId && contextWorkspaceId + ? sessionRecordWorkspaceId !== contextWorkspaceId && sessionProjectRecordWorkspaceId !== contextWorkspaceId : !!sessionPath && (!contextPath || !isSamePath(sessionPath, contextPath)) && !( @@ -2460,7 +2481,8 @@ export const ChatInput: React.FC = ({ const response = await agentAPI.getSessionPermissionMode({ sessionId: permissionSessionId, turnId: activePermissionTurnId ?? undefined, - workspacePath: effectiveTargetSession?.workspacePath, + workspaceId: sessionOwningId, + workspacePath: sessionOwningPath, remoteConnectionId: effectiveTargetSession?.remoteConnectionId, remoteSshHost: effectiveTargetSession?.remoteSshHost, }); @@ -2494,6 +2516,8 @@ export const ChatInput: React.FC = ({ }, [ activePermissionTurnId, effectiveTargetSessionId, + sessionOwningId, + sessionOwningPath, effectiveTargetSession?.workspacePath, effectiveTargetSession?.remoteConnectionId, effectiveTargetSession?.remoteSshHost, @@ -2524,7 +2548,8 @@ export const ChatInput: React.FC = ({ sessionId: targetSessionId, mode: nextMode, turnId: targetTurnId ?? undefined, - workspacePath: effectiveTargetSession?.workspacePath, + workspaceId: sessionOwningId, + workspacePath: sessionOwningPath, remoteConnectionId: effectiveTargetSession?.remoteConnectionId, remoteSshHost: effectiveTargetSession?.remoteSshHost, }); @@ -2558,7 +2583,8 @@ export const ChatInput: React.FC = ({ } }, [ effectiveTargetSessionId, - effectiveTargetSession?.workspacePath, + sessionOwningId, + sessionOwningPath, effectiveTargetSession?.remoteConnectionId, effectiveTargetSession?.remoteSshHost, activeTurnPermissionMode, @@ -2645,7 +2671,8 @@ export const ChatInput: React.FC = ({ sessionId: targetSessionId, turnId: targetTurnId, mode: nextTemporaryMode, - workspacePath: effectiveTargetSession?.workspacePath, + workspaceId: sessionOwningId, + workspacePath: sessionOwningPath, remoteConnectionId: effectiveTargetSession?.remoteConnectionId, remoteSshHost: effectiveTargetSession?.remoteSshHost, }); @@ -2682,9 +2709,10 @@ export const ChatInput: React.FC = ({ confirmFullAccessIfNeeded, effectiveTargetSession?.remoteConnectionId, effectiveTargetSession?.remoteSshHost, - effectiveTargetSession?.workspacePath, effectiveTargetSessionId, isAcpTargetSession, + sessionOwningId, + sessionOwningPath, permissionModeSaving, t, ]); @@ -6109,7 +6137,7 @@ export const ChatInput: React.FC = ({ ); const workspaceStrip = workspaceStripVisible ? ( = ( { confirmText: t('flowChatHeader.agentTreeDelete') }, ); if (!confirmed) return false; - await deleteSessionTreeBranch({ sessionId: selection.sessionId, workspaceId: requireSessionWorkspaceId(flowChatStore.getState().sessions.get(selection.sessionId) || activeSession!) }, scope); + await deleteSessionTreeBranch({ sessionId: selection.sessionId, workspaceId: requireSessionOwningWorkspaceId(flowChatStore.getState().sessions.get(selection.sessionId) || activeSession!) }, scope); return true; } catch (error) { if (!isSurfaceChangedError(error)) { diff --git a/src/web-ui/src/flow_chat/services/BtwThreadService.ts b/src/web-ui/src/flow_chat/services/BtwThreadService.ts index ef6f4ae2ef..42f7a5b1e8 100644 --- a/src/web-ui/src/flow_chat/services/BtwThreadService.ts +++ b/src/web-ui/src/flow_chat/services/BtwThreadService.ts @@ -183,6 +183,11 @@ export async function createBtwChildSession(params: { reviewTargetFilePaths: params.reviewTargetFilePaths, projectWorkspacePath: createdSession?.projectWorkspacePath || projectWorkspacePath, + // The child owns the parent's project, so navigation and persistence + // resolve both to the same group. + projectWorkspaceId: + parentSession?.projectWorkspaceId + || parentSession?.config.projectWorkspaceId, executionTarget: createdSession?.executionTarget || inheritedExecutionTarget, workspaceId: createdSession?.workspaceId || workspaceId, diff --git a/src/web-ui/src/flow_chat/services/ReviewActionBarPersistenceService.ts b/src/web-ui/src/flow_chat/services/ReviewActionBarPersistenceService.ts index 7896910bc9..d62b502da2 100644 --- a/src/web-ui/src/flow_chat/services/ReviewActionBarPersistenceService.ts +++ b/src/web-ui/src/flow_chat/services/ReviewActionBarPersistenceService.ts @@ -11,7 +11,7 @@ import { flowChatStore } from '../store/FlowChatStore'; import { buildSessionMetadata } from '../utils/sessionMetadata'; import type { ReviewActionBarState } from '../store/deepReviewActionBarStore'; import type { ReviewActionPersistedState, SessionMetadata } from '@/shared/types/session-history'; -import { requireSessionWorkspaceId } from '../utils/sessionWorkspace'; +import { requireSessionOwningWorkspaceId } from '../utils/sessionOrdering'; const log = createLogger('ReviewActionBarPersistence'); @@ -20,7 +20,7 @@ export async function persistReviewActionState(state: ReviewActionBarState): Pro const session = flowChatStore.getState().sessions.get(state.childSessionId); if (!session) return; - const workspaceId = requireSessionWorkspaceId(session); + const workspaceId = requireSessionOwningWorkspaceId(session); const stateReviewTargetFilePaths = state.reviewTargetFilePaths ?? []; const remediationModifiedFilePaths = state.remediationModifiedFilePaths ?? []; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 2c42e9b5f3..1274228aa6 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -531,6 +531,11 @@ function ensureSubagentSession( parentSession?.projectWorkspacePath || parentSession?.config.projectWorkspacePath || parentSession?.workspacePath, + // The child owns the parent's project, so navigation and persistence + // resolve both to the same group. + projectWorkspaceId: + parentSession?.projectWorkspaceId + || parentSession?.config.projectWorkspaceId, executionTarget: parentSession?.config.executionTarget, workspaceId: subagentWorkspace.workspaceId, }, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts index 89a7cfa5bd..e117899fa4 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts @@ -59,7 +59,7 @@ function createDialogTurn(status: DialogTurn['status'] = 'processing'): DialogTu }; } -function createContext(dialogTurn: DialogTurn): any { +function createContext(dialogTurn: DialogTurn, sessionPatch: Record = {}): any { const session = { sessionId: SESSION_ID, dialogTurns: [dialogTurn], @@ -71,6 +71,7 @@ function createContext(dialogTurn: DialogTurn): any { config: {}, error: null, sessionKind: 'normal', + ...sessionPatch, }; return { @@ -118,6 +119,27 @@ describe('PersistenceModule', () => { }), 'workspace-local', ['unreadCompletion', 'needsUserAttention']); }); + it('addresses session storage through the owning project for a worktree session', async () => { + const turn = createDialogTurn('completed'); + const context = createContext(turn, { + workspaceId: 'worktree-cli', + projectWorkspaceId: 'main-project', + config: { + executionTarget: { kind: 'managedWorktree', worktreeId: 'worktree-cli', rootPath: '/tmp/tree' }, + }, + }); + + await updateSessionMetadata(context, SESSION_ID, ['unreadCompletion', 'needsUserAttention']); + + // The execution worktree is usually not an open workspace, so addressing the + // session's own storage through it is rejected, while the owning project + // resolves to the identical session directory. + expect(mockSaveSessionMetadata).toHaveBeenCalledWith(expect.anything(), 'main-project', [ + 'unreadCompletion', + 'needsUserAttention', + ]); + }); + it('never writes old notification metadata to a device selected during its read', async () => { const original = getActiveSurfaceId(); try { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 3a72cd52db..195d9ea03e 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -1,4 +1,4 @@ -import { requireSessionWorkspaceId } from '../../utils/sessionWorkspace'; +import { requireSessionOwningWorkspaceId } from '../../utils/sessionOrdering'; /** * Persistence module * Handles persistence operations for dialog turn saving and metadata management @@ -325,7 +325,7 @@ async function performSaveDialogTurnToDisk( const turnData = convertDialogTurnToBackendFormat(dialogTurn, turnIndex); await sessionAPI.saveSessionTurn( turnData, - requireSessionWorkspaceId(session)); + requireSessionOwningWorkspaceId(session)); await updateSessionMetadata(context, sessionId); @@ -562,7 +562,7 @@ export async function updateSessionMetadata( if (!fields) { existingMetadata = await sessionAPI.loadSessionMetadata( sessionId, - requireSessionWorkspaceId(session)); + requireSessionOwningWorkspaceId(session)); } } catch { // ignore @@ -573,7 +573,7 @@ export async function updateSessionMetadata( await sessionAPI.saveSessionMetadata( metadata, - requireSessionWorkspaceId(session), + requireSessionOwningWorkspaceId(session), fields ?? [ 'sessionName', 'tags', diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index a046333a77..74832138c7 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -1,3 +1,4 @@ +import { requireSessionOwningWorkspaceId } from '../../utils/sessionOrdering'; import { requireSessionWorkspaceId } from '../../utils/sessionWorkspace'; /** * Session management module @@ -133,7 +134,7 @@ async function hydrateHistoricalSession( const surfaceScope = getActiveSurfaceScope(); const initialSession = context.flowChatStore.getState().sessions.get(sessionId); if (!initialSession) return; - const workspaceId = requireSessionWorkspaceId(initialSession); + const workspaceId = requireSessionOwningWorkspaceId(initialSession); const pendingKey = pendingHistoryLoadKey(sessionId, surfaceScope); const existing = context.pendingHistoryLoads.get(pendingKey); if (existing) { @@ -544,7 +545,7 @@ export async function switchChatSession( } touchSessionActivity( sessionId, - requireSessionWorkspaceId(latestSession) + requireSessionOwningWorkspaceId(latestSession) ).catch(error => { if (isSurfaceChangedError(error)) { return; @@ -743,7 +744,7 @@ export async function reloadSessionTitle( const metadata = await sessionAPI.loadSessionMetadata( sessionId, - requireSessionWorkspaceId(session)); + requireSessionOwningWorkspaceId(session)); if (!metadata) return; const titleState = deriveSessionTitleStateFromMetadata(metadata); @@ -786,7 +787,7 @@ export async function forkChatSession( const response = await sessionAPI.forkSession( sourceSessionId, sourceTurnId, - requireSessionWorkspaceId(sourceSession)); + requireSessionOwningWorkspaceId(sourceSession)); const currentState = context.flowChatStore.getState(); if (!currentState.sessions.has(response.sessionId)) { diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts index a891f02088..4a9e0b3ef2 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts @@ -121,7 +121,7 @@ describe('openMainSession resource activation', () => { expect(mocks.sceneState.openScene).not.toHaveBeenCalled(); }); - it('activates the workspace a worktree session is listed under, not its project', async () => { + it('activates a worktree session in its own project without switching workspace', async () => { const project = { id: 'project', rootPath: '/projects/main', workspaceKind: 'normal' }; const worktree = { id: 'worktree', rootPath: '/projects/tree', workspaceKind: 'normal', @@ -133,6 +133,34 @@ describe('openMainSession resource activation', () => { mocks.flowChatState.sessions.set('tree-session', { sessionId: 'tree-session', workspaceId: worktree.id, projectWorkspaceId: project.id, workspacePath: worktree.rootPath, projectWorkspacePath: project.rootPath, + config: { + executionTarget: { kind: 'managedWorktree', worktreeId: 'wt-1', rootPath: worktree.rootPath }, + }, + }); + mocks.switchChatSession.mockImplementation(async (id: string) => { + mocks.flowChatState.activeSessionId = id; + }); + + await openMainSession('tree-session'); + + expect(mocks.setActiveWorkspace).not.toHaveBeenCalled(); + expect(mocks.switchChatSession.mock.calls.map(([id]) => id)).toEqual(['tree-session']); + expect(mocks.sceneState.openScene).toHaveBeenCalledWith('session'); + }); + + it('activates a session created in an open worktree workspace inside that workspace', async () => { + const project = { id: 'project', rootPath: '/projects/main', workspaceKind: 'normal' }; + const worktree = { + id: 'worktree', rootPath: '/projects/tree', workspaceKind: 'normal', + worktree: { isMain: false, mainRepoPath: '/projects/main', mainWorkspaceId: project.id }, + }; + mocks.workspaceState.currentWorkspace = project; + mocks.workspaceState.activeWorkspaceId = project.id; + mocks.workspaceState.openedWorkspaces = new Map([[project.id, project], [worktree.id, worktree]]); + mocks.flowChatState.sessions.set('in-tree-session', { + sessionId: 'in-tree-session', workspaceId: worktree.id, projectWorkspaceId: project.id, + workspacePath: worktree.rootPath, projectWorkspacePath: project.rootPath, + config: { executionTarget: { kind: 'local', rootPath: worktree.rootPath } }, }); mocks.setActiveWorkspace.mockImplementation(async (id: string) => { mocks.workspaceState.activeWorkspaceId = id; @@ -141,13 +169,33 @@ describe('openMainSession resource activation', () => { mocks.flowChatState.activeSessionId = id; }); - await openMainSession('tree-session'); + await openMainSession('in-tree-session'); expect(mocks.setActiveWorkspace.mock.calls.map(([id]) => id)).toEqual([worktree.id]); - expect(mocks.switchChatSession.mock.calls.map(([id]) => id)).toEqual(['tree-session']); + expect(mocks.switchChatSession.mock.calls.map(([id]) => id)).toEqual(['in-tree-session']); expect(mocks.workspaceState.activeWorkspaceId).toBe(worktree.id); }); + it('reports a worktree session whose project is not open instead of switching to the worktree', async () => { + const project = { id: 'project', rootPath: '/projects/main', workspaceKind: 'normal' }; + const worktree = { id: 'worktree', rootPath: '/projects/tree', workspaceKind: 'normal' }; + mocks.workspaceState.currentWorkspace = project; + mocks.workspaceState.activeWorkspaceId = project.id; + mocks.workspaceState.openedWorkspaces = new Map([[project.id, project]]); + mocks.flowChatState.sessions.set('tree-session', { + sessionId: 'tree-session', workspaceId: worktree.id, projectWorkspaceId: 'unopened-project', + workspacePath: worktree.rootPath, projectWorkspacePath: '/projects/other', + config: { + executionTarget: { kind: 'managedWorktree', worktreeId: 'wt-1', rootPath: worktree.rootPath }, + }, + }); + + await expect(openMainSession('tree-session')).rejects.toThrow(); + + expect(mocks.setActiveWorkspace).not.toHaveBeenCalled(); + expect(mocks.switchChatSession).not.toHaveBeenCalled(); + }); + it('orders workspace activation and only opens the latest requested session', async () => { const a = { id: 'a', rootPath: '/a' }; const b = { id: 'b', rootPath: '/b' }; diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.ts b/src/web-ui/src/flow_chat/services/sessionActivation.ts index 7df075a211..86415112c6 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.ts @@ -5,7 +5,7 @@ import { flowChatManager } from './FlowChatManager'; import { syncSessionToModernStore } from './storeSync'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; -import { resolveSessionActivationWorkspace } from '@/app/services/sessionSceneTarget'; +import { resolveSessionSceneWorkspace } from '@/app/services/sessionSceneTarget'; import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; import { i18nService } from '@/infrastructure/i18n'; @@ -38,7 +38,7 @@ export async function activateMainSession(sessionId: string, options?: SessionAc const isCurrent = () => scope.isCurrent() && request === activationRequest && (options?.isCurrent?.() ?? true); if (!isCurrent()) return false; const session = flowChatStore.getState().sessions.get(sessionId); - const workspace = session && resolveSessionActivationWorkspace(session, workspaceManager.getState().openedWorkspaces.values()); + const workspace = session && resolveSessionSceneWorkspace(session, workspaceManager.getState().openedWorkspaces.values()); const workspaceId = options?.workspaceId ?? workspace?.id; if (session && sessionProjectWorkspacePath(session) && !workspaceId && workspaceManager.getState().currentWorkspace) { diff --git a/src/web-ui/src/flow_chat/services/usageReportService.ts b/src/web-ui/src/flow_chat/services/usageReportService.ts index 5ad2c03a1e..931e4f3ef9 100644 --- a/src/web-ui/src/flow_chat/services/usageReportService.ts +++ b/src/web-ui/src/flow_chat/services/usageReportService.ts @@ -7,7 +7,7 @@ import { showSessionUsageModalReport, } from '../components/usage/sessionUsageModalState'; import type { Session } from '../types/flow-chat'; -import { requireSessionWorkspaceId } from '../utils/sessionWorkspace'; +import { requireSessionOwningWorkspaceId } from '../utils/sessionOrdering'; import { i18nService } from '@/infrastructure/i18n'; import { formatCacheHitRate, @@ -75,7 +75,7 @@ export async function runUsageReportCommand( ? await params.fetchReport() : await sessionAPI.getSessionUsageReport({ sessionId: params.session.sessionId, - workspaceId: requireSessionWorkspaceId(params.session), + workspaceId: requireSessionOwningWorkspaceId(params.session), includeHiddenSubagents: true, }); const report = enrichUsageReportModelIdentity(rawReport, params.session); diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 64fd224a6d..3c9d13f15f 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -1,4 +1,4 @@ -import { requireSessionWorkspaceId, sessionWorkspaceId } from '../../utils/sessionWorkspace'; +import { requireSessionOwningWorkspaceId, sessionOwningWorkspaceId } from '../../utils/sessionOrdering'; /** * Local session driver: the default flavor backed by this machine's (or the * attached peer's) agent runtime via `agentAPI`. @@ -37,6 +37,7 @@ import { nextStorageTurnIndex } from '../../utils/flowChatTurnIdentity'; import { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; import { sessionProjectWorkspacePath, + sessionWorkspaceId, } from '../../utils/sessionWorkspace'; import { sessionWorktreeMaterializationPlan } from '../../utils/sessionWorktree'; import { cleanupSaveState, updateSessionMetadata } from '../../services/flow-chat-manager/PersistenceModule'; @@ -115,7 +116,7 @@ export const localSessionDriver: SessionDriver = { }; const createdTitleDescriptor = await initializeSessionTitleMetadata( - response.sessionId, titleDescriptor, requireSessionWorkspaceId({ config: resolvedConfig }), + response.sessionId, titleDescriptor, requireSessionOwningWorkspaceId({ config: resolvedConfig }), surfaceScope, ); @@ -168,7 +169,7 @@ export const localSessionDriver: SessionDriver = { await sessionAPI.archiveSession( sessionId, - requireSessionWorkspaceId(session)); + requireSessionOwningWorkspaceId(session)); context.flowChatStore.removeSession( sessionId, @@ -196,7 +197,7 @@ export const localSessionDriver: SessionDriver = { const updatedTitle = await agentAPI.updateSessionTitle({ sessionId, title, - workspaceId: sessionWorkspaceId(session), + workspaceId: sessionOwningWorkspaceId(session), workspacePath: sessionProjectWorkspacePath(session), remoteConnectionId: session.remoteConnectionId, remoteSshHost: session.remoteSshHost, @@ -255,8 +256,8 @@ export const localSessionDriver: SessionDriver = { } await agentAPI.compactSession({ sessionId, - workspaceId: sessionWorkspaceId(session), - workspacePath: session.workspacePath, + workspaceId: sessionOwningWorkspaceId(session), + workspacePath: sessionProjectWorkspacePath(session), remoteConnectionId: session.remoteConnectionId, remoteSshHost: session.remoteSshHost, }); @@ -407,6 +408,7 @@ export const localSessionDriver: SessionDriver = { workspacePath: result.workspacePath, projectWorkspacePath: result.projectWorkspacePath, workspaceId: result.workspaceId, + projectWorkspaceId: result.projectWorkspaceId, executionTarget: result.executionTarget, }); if (result.retainedWorktreePath) { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index c0c8f7f81f..dccc6cf269 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -317,6 +317,108 @@ describe('FlowChatStore lazy worktree preference', () => { .worktreeIsolationRequested, ).toBeUndefined(); }); + + it('adopts the project identity reported by a worktree binding', () => { + const session = createSession({ + config: { + agentType: 'Standard', + workspacePath: '/repo', + projectWorkspacePath: '/repo', + executionTarget: { kind: 'local', rootPath: '/repo' }, + }, + workspacePath: '/repo', + projectWorkspacePath: '/repo', + workspaceId: 'workspace-project', + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + flowChatStore.updateSessionExecutionTarget(session.sessionId, { + workspacePath: '/worktrees/streams-cli', + projectWorkspacePath: '/repo', + workspaceId: 'workspace-worktree', + projectWorkspaceId: 'workspace-project', + executionTarget: { + kind: 'managedWorktree', + worktreeId: 'workspace-worktree', + rootPath: '/worktrees/streams-cli', + }, + }); + + expect(flowChatStore.getState().sessions.get(session.sessionId)).toMatchObject({ + workspacePath: '/worktrees/streams-cli', + workspaceId: 'workspace-worktree', + projectWorkspaceId: 'workspace-project', + config: { workspaceId: 'workspace-worktree', projectWorkspaceId: 'workspace-project' }, + }); + }); + + it('keeps the previous project identity when a binding reports none', () => { + const session = createSession({ + config: { + agentType: 'Standard', + workspacePath: '/repo', + projectWorkspacePath: '/repo', + executionTarget: { kind: 'local', rootPath: '/repo' }, + }, + workspacePath: '/repo', + projectWorkspacePath: '/repo', + workspaceId: 'workspace-project', + projectWorkspaceId: 'workspace-project', + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + flowChatStore.updateSessionExecutionTarget(session.sessionId, { + workspacePath: '/repo', + projectWorkspacePath: '/repo', + executionTarget: { kind: 'local', rootPath: '/repo' }, + }); + + expect(flowChatStore.getState().sessions.get(session.sessionId)).toMatchObject({ + workspaceId: 'workspace-project', + projectWorkspaceId: 'workspace-project', + }); + }); + + it('owns a worktree session through the workspace it moved away from', () => { + const session = createSession({ + config: { + agentType: 'Standard', + workspacePath: '/repo', + projectWorkspacePath: '/repo', + executionTarget: { kind: 'local', rootPath: '/repo' }, + }, + workspacePath: '/repo', + projectWorkspacePath: '/repo', + workspaceId: 'workspace-project', + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + flowChatStore.updateSessionExecutionTarget(session.sessionId, { + workspacePath: '/worktrees/streams-cli', + projectWorkspacePath: '/repo', + workspaceId: 'workspace-worktree', + executionTarget: { + kind: 'managedWorktree', + worktreeId: 'workspace-worktree', + rootPath: '/worktrees/streams-cli', + }, + }); + + expect(flowChatStore.getState().sessions.get(session.sessionId)).toMatchObject({ + workspaceId: 'workspace-worktree', + projectWorkspaceId: 'workspace-project', + config: { workspaceId: 'workspace-worktree', projectWorkspaceId: 'workspace-project' }, + }); + }); }); describe('FlowChatStore dispatch observer boundaries', () => { @@ -1653,6 +1755,58 @@ describe('FlowChatStore historical session hydration state', () => { expect(order).toEqual(['restore']); }); + it('reads worktree session history through the project that owns it', async () => { + const projectWorkspaceId = fixtureWorkspaceId('/repo'); + const worktreeWorkspaceId = fixtureWorkspaceId('/worktrees/streams-cli'); + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'Standard', + state: 'Idle', + turnCount: 0, + createdAt: 1, + }, + turns: [], + contextRestoreState: 'ready', + }); + const session = createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + workspaceId: worktreeWorkspaceId, + projectWorkspaceId, + workspacePath: '/worktrees/streams-cli', + projectWorkspacePath: '/repo', + config: { + agentType: 'Standard', + workspaceId: worktreeWorkspaceId, + projectWorkspaceId, + workspacePath: '/worktrees/streams-cli', + projectWorkspacePath: '/repo', + executionTarget: { + kind: 'managedWorktree', + worktreeId: worktreeWorkspaceId, + rootPath: '/worktrees/streams-cli', + }, + }, + }); + flowChatStore.setState(() => ({ + sessions: new Map([['history-1', session]]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledWith( + 'history-1', + projectWorkspaceId, + expect.any(String), + undefined, + expect.any(Number), + ); + }); + it('restores history from the peer host in Peer Device Mode', async () => { peerModeFlagMock.active = true; diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index e90215e373..3a719759ca 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1,4 +1,3 @@ -import { requireSessionWorkspaceId } from '../utils/sessionWorkspace'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { resolveLegacySessionWorkspace } from '@/infrastructure/api/service-api/legacyWorkspaceCompatibility'; import { projectUserQuestionTiming } from '../utils/userQuestionTiming'; @@ -81,6 +80,7 @@ import { deriveSessionRelationshipFromMetadata, normalizeSessionRelationship, } from '../utils/sessionMetadata'; +import { sessionOwningWorkspaceId } from '../utils/sessionOrdering'; import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; import type { SessionTitleDescriptor } from '../utils/sessionTitle'; import { deriveContextUsageFromTurns } from '../utils/tokenUsageDisplay'; @@ -2791,9 +2791,21 @@ export class FlowChatStore { } } - private sessionWorkspaceId(sessionId: string): string { + /** + * Workspace identity every persisted-history read is addressed with. + * + * The ID is a storage selector, not the execution directory: the backend + * resolves the session directory through the workspace record it is given, and + * an isolated worktree record resolves to the same directory as the project it + * belongs to. A worktree record is created on demand and can be absent from the + * open workspace set, which the backend rejects outright, so history is always + * read through the owning project the navigation list groups the session under. + */ + private sessionHistoryWorkspaceId(sessionId: string): string { const session = this.state.sessions.get(sessionId); - const workspaceId = session?.workspaceId ?? session?.config.workspaceId; + const workspaceId = session + ? sessionOwningWorkspaceId(session) + : undefined; if (!workspaceId) throw new Error(`Workspace ID is unavailable for session: ${sessionId}`); return workspaceId; } @@ -3036,7 +3048,7 @@ export class FlowChatStore { } const canonicalTurns = canonicalSessionTurns(session); - const workspaceId = session.workspaceId ?? session.config.workspaceId; + const workspaceId = sessionOwningWorkspaceId(session); if (!workspaceId || canonicalTurns.length === 0) { return false; } @@ -3106,7 +3118,7 @@ export class FlowChatStore { ); if (!hydrationRequest) { const canonicalTurns = canonicalSessionTurns(session); - const workspaceId = session.workspaceId ?? session.config.workspaceId; + const workspaceId = sessionOwningWorkspaceId(session); if (!workspaceId || canonicalTurns.length === 0) { this.fullHistoryProjectionApplyRequests.delete(sessionId); return false; @@ -4253,6 +4265,7 @@ export class FlowChatStore { reviewTargetEvidence?: Session['reviewTargetEvidence']; reviewTargetFilePaths?: Session['reviewTargetFilePaths']; projectWorkspacePath?: string; + projectWorkspaceId?: string; executionTarget?: Session['config']['executionTarget']; workspaceId?: string; }, @@ -4286,6 +4299,7 @@ export class FlowChatStore { projectWorkspacePath: meta?.projectWorkspacePath, executionTarget: meta?.executionTarget, workspaceId: meta?.workspaceId, + projectWorkspaceId: meta?.projectWorkspaceId, } as any, createdAt: Date.now(), lastActiveAt: Date.now(), @@ -4300,6 +4314,7 @@ export class FlowChatStore { workspacePath, projectWorkspacePath: meta?.projectWorkspacePath, workspaceId: meta?.workspaceId, + projectWorkspaceId: meta?.projectWorkspaceId, remoteConnectionId, remoteSshHost, parentSessionId: relationship.parentSessionId, @@ -4667,7 +4682,10 @@ export class FlowChatStore { /** * Apply a backend session rebind (worktree isolation toggled on or off). - * The project root stays put; only the execution directory moves. + * The project root stays put; only the execution directory moves. A binding + * that reports the owning project fills a project identity the session was + * created without, and the workspace a session moves away from supplies it + * when the binding reports none. */ public updateSessionExecutionTarget( sessionId: string, @@ -4675,6 +4693,7 @@ export class FlowChatStore { workspacePath: string; projectWorkspacePath: string; workspaceId?: string; + projectWorkspaceId?: string; executionTarget: Session['config']['executionTarget']; }, ): void { @@ -4683,16 +4702,28 @@ export class FlowChatStore { if (!session) return prev; const newSessions = new Map(prev.sessions); + // A binding that moves this session into an isolated execution directory + // must never leave it without an owning project: the worktree record it + // then carries is an on-demand execution record that owns no navigation + // row the user can open. A backend that reports no project ID is answered + // with the workspace the session moved away from, which is that project. + const isolated = !!binding.executionTarget && binding.executionTarget.kind !== 'local'; + const owningProjectWorkspaceId = binding.projectWorkspaceId + ?? session.projectWorkspaceId + ?? session.config.projectWorkspaceId + ?? (isolated ? session.workspaceId ?? session.config.workspaceId : undefined); newSessions.set(sessionId, { ...session, workspacePath: binding.workspacePath, projectWorkspacePath: binding.projectWorkspacePath, workspaceId: binding.workspaceId ?? session.workspaceId, + projectWorkspaceId: owningProjectWorkspaceId, config: { ...session.config, workspacePath: binding.workspacePath, projectWorkspacePath: binding.projectWorkspacePath, workspaceId: binding.workspaceId ?? session.config.workspaceId, + projectWorkspaceId: owningProjectWorkspaceId, executionTarget: binding.executionTarget, }, lastActiveAt: Date.now(), @@ -5199,7 +5230,7 @@ export class FlowChatStore { await agentAPI.deleteSession( id, - requireSessionWorkspaceId(sess!) + this.sessionHistoryWorkspaceId(id) ); }) ); @@ -7001,7 +7032,7 @@ export class FlowChatStore { await sessionAPI.saveSessionTurn( turnData, - requireSessionWorkspaceId(session)); + this.sessionHistoryWorkspaceId(sessionId)); } catch (error) { log.error('Failed to save cancelled dialog turn', { sessionId, turnId, error }); } @@ -7837,7 +7868,7 @@ export class FlowChatStore { const restored = await agentAPI.restoreSessionView( sessionId, - this.sessionWorkspaceId(sessionId), + this.sessionHistoryWorkspaceId(sessionId), `peer-refresh-${sessionId.slice(0, 8)}`, undefined, PEER_SESSION_REFRESH_TAIL_TURN_COUNT, @@ -8097,7 +8128,7 @@ export class FlowChatStore { const restored = await agentAPI.restoreSessionView( sessionId, - this.sessionWorkspaceId(sessionId), + this.sessionHistoryWorkspaceId(sessionId), `settled-turn-${turnId.slice(0, 8)}`, initialSession.sessionKind === 'subagent', SETTLED_TURN_RECONCILE_TAIL_TURN_COUNT, @@ -8384,7 +8415,7 @@ export class FlowChatStore { try { const restoredPromise = agentAPI.restoreSessionWithTurns( sessionId, - this.sessionWorkspaceId(sessionId), + this.sessionHistoryWorkspaceId(sessionId), sessionTraceId, options?.includeInternal, ); @@ -8412,7 +8443,7 @@ export class FlowChatStore { const restoredSessionPromise = agentAPI.restoreSession( sessionId, - this.sessionWorkspaceId(sessionId), + this.sessionHistoryWorkspaceId(sessionId), sessionTraceId, options?.includeInternal, ); @@ -8428,7 +8459,7 @@ export class FlowChatStore { try { const restoredPromise = agentAPI.restoreSessionView( sessionId, - this.sessionWorkspaceId(sessionId), + this.sessionHistoryWorkspaceId(sessionId), sessionTraceId, options?.includeInternal, historicalSessionInitialTailTurnCount(remote), @@ -8510,7 +8541,7 @@ export class FlowChatStore { sessionTraceId, }); const { sessionAPI } = await import('@/infrastructure/api/service-api/SessionAPI'); - turns = await sessionAPI.loadSessionTurns(sessionId, requireSessionWorkspaceId(initialSession!), options?.limit); + turns = await sessionAPI.loadSessionTurns(sessionId, this.sessionHistoryWorkspaceId(sessionId), options?.limit); startupTrace.markPhase('historical_session_turns_load_end', { remote, sessionId, @@ -8754,7 +8785,7 @@ export class FlowChatStore { } else if (!deferFullHistoryUntilActive) { this.scheduleCompleteSessionHistoryLoad({ sessionId, - workspaceId: this.sessionWorkspaceId(sessionId), + workspaceId: this.sessionHistoryWorkspaceId(sessionId), remoteConnectionId, remoteSshHost, includeInternal: options?.includeInternal, diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts index 08935646ed..29f3dfff1e 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts @@ -6,7 +6,9 @@ import { compareSessionsForNavStable, getSessionMetadataSortTimestamp, getSessionSortTimestamp, + requireSessionOwningWorkspaceId, sessionBelongsToWorkspaceNavRow, + sessionOwningWorkspaceId, } from './sessionOrdering'; function createSession(overrides: Partial = {}): Session { @@ -104,16 +106,48 @@ describe('sessionOrdering', () => { expect(sessionBelongsToWorkspaceNavRow({ workspaceId: 'host-b-project' }, 'host-a-project')).toBe(false); }); - it('does not show a worktree session under its main project group', () => { + it('keeps a session whose execution workspace is not a worktree in its own group', () => { + const session = { + workspaceId: 'worktree-cli', + projectWorkspaceId: 'main-project', + }; + expect(sessionBelongsToWorkspaceNavRow(session, 'worktree-cli')).toBe(true); + expect(sessionBelongsToWorkspaceNavRow(session, 'main-project')).toBe(false); + expect(sessionBelongsToWorkspaceNavRow(session, 'sibling-worktree')).toBe(false); + }); + + it('keeps a worktree-isolated session under the project that owns it', () => { const worktreeSession = { workspaceId: 'worktree-cli', projectWorkspaceId: 'main-project', + config: { + executionTarget: { + kind: 'managedWorktree' as const, + worktreeId: 'worktree-cli', + rootPath: '/tmp/worktrees/cli', + }, + }, }; - expect(sessionBelongsToWorkspaceNavRow(worktreeSession, 'worktree-cli')).toBe(true); - expect(sessionBelongsToWorkspaceNavRow(worktreeSession, 'main-project')).toBe(false); + expect(sessionBelongsToWorkspaceNavRow(worktreeSession, 'main-project')).toBe(true); + expect(sessionBelongsToWorkspaceNavRow(worktreeSession, 'worktree-cli')).toBe(false); expect(sessionBelongsToWorkspaceNavRow(worktreeSession, 'sibling-worktree')).toBe(false); }); + it('falls back to the execution workspace when a worktree session has no project ID', () => { + const worktreeSession = { + workspaceId: 'worktree-only', + config: { + executionTarget: { + kind: 'existingWorktree' as const, + worktreeId: 'worktree-only', + rootPath: '/tmp/worktrees/only', + }, + }, + }; + expect(sessionBelongsToWorkspaceNavRow(worktreeSession, 'worktree-only')).toBe(true); + expect(sessionBelongsToWorkspaceNavRow(worktreeSession, 'main-project')).toBe(false); + }); + it('still attributes a legacy record that only carries the project ID', () => { const legacySession = { projectWorkspaceId: 'main-project' }; expect(sessionBelongsToWorkspaceNavRow(legacySession, 'main-project')).toBe(true); @@ -125,4 +159,34 @@ describe('sessionOrdering', () => { expect(sessionBelongsToWorkspaceNavRow({ workspaceId: 'stale' }, 'known')).toBe(false); expect(sessionBelongsToWorkspaceNavRow({ workspaceId: 'known' }, undefined)).toBe(false); }); + + it('names one owning workspace ID per session shape', () => { + expect(sessionOwningWorkspaceId({ + workspaceId: 'worktree-cli', projectWorkspaceId: 'main-project', + config: { + executionTarget: { kind: 'managedWorktree', worktreeId: 'worktree-cli', rootPath: '/tmp/tree' }, + }, + })).toBe('main-project'); + expect(sessionOwningWorkspaceId({ + workspaceId: 'worktree-ws', projectWorkspaceId: 'main-project', + config: { executionTarget: { kind: 'local', rootPath: '/tmp/tree' } }, + })).toBe('worktree-ws'); + expect(sessionOwningWorkspaceId({ config: { projectWorkspaceId: 'main-project' } })) + .toBe('main-project'); + expect(sessionOwningWorkspaceId({})).toBeUndefined(); + }); + + it('addresses session commands through the owning project, not the execution worktree', () => { + expect(requireSessionOwningWorkspaceId({ + workspaceId: 'worktree-cli', projectWorkspaceId: 'main-project', + config: { + executionTarget: { kind: 'managedWorktree', worktreeId: 'worktree-cli', rootPath: '/tmp/tree' }, + }, + })).toBe('main-project'); + expect(requireSessionOwningWorkspaceId({ workspaceId: 'main-project' })).toBe('main-project'); + }); + + it('refuses to address session commands when the session carries no workspace identity', () => { + expect(() => requireSessionOwningWorkspaceId({})).toThrow('Session workspace ID is unavailable'); + }); }); diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts index be667adc2f..3235e3d912 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts @@ -1,18 +1,82 @@ import type { Session } from '../types/flow-chat'; import type { SessionMetadata } from '@/shared/types/session-history'; /** - * Session list membership is the owning execution workspace ID (`workspaceId`), - * never a path. `projectWorkspaceId` is only the persistence owner (the main - * project for linked worktrees) and a legacy fallback for records created - * before execution-workspace stamping; it must never widen membership to - * sibling worktrees of the same project. + * Session facts that decide which navigation group owns a session. `config` is + * optional so metadata-only and legacy call sites keep working. + */ +type SessionNavigationOwner = Pick & { + config?: Pick; +}; + +/** + * The session executes in a managed worktree of its owning project. + * + * A worktree is an execution directory, not a project of its own, so this fact + * is what keeps such a session in its project's navigation group. + */ +export function isWorktreeIsolatedSession(session: SessionNavigationOwner): boolean { + const target = session.config?.executionTarget; + return !!target && target.kind !== 'local'; +} + +/** + * Workspace ID of the navigation row that owns the session. + * + * This is the one session-to-workspace identity the whole shell shares: the left + * navigation list, the workspace bootstrap that selects a session, the scene tab + * key, and session activation. They must never disagree, or a session is listed + * under one workspace while activation moves the surface to another. + * + * A worktree-isolated session is stamped with the worktree's own workspace record + * but is owned by the project it was started from, so the project ID wins. Every + * other session is owned by the workspace it was created in — including one + * created while browsing a linked worktree as its own workspace, which stays in + * that worktree row even though the worktree's `project_workspace_id` points at + * the main checkout. + * + * Top-level IDs are authoritative; `config` copies serve legacy records that were + * persisted without them. + */ +export function sessionOwningWorkspaceId(session: SessionNavigationOwner): string | undefined { + const projectId = session.projectWorkspaceId ?? session.config?.projectWorkspaceId; + const executionId = session.workspaceId ?? session.config?.workspaceId; + return isWorktreeIsolatedSession(session) ? projectId ?? executionId : executionId ?? projectId; +} + +/** + * Session list membership is the owning project workspace ID, never a path. + * + * The backend stamps a worktree-isolated session with the worktree's own + * workspace record, but that record is created on demand and is normally not an + * open workspace. Following it would drop the session out of every navigation + * group the user can see, so an isolated session stays under the project that + * owns it — the project identity is the one the worktree cannot outlive. + * + * Legacy records without any workspace identity stay unresolved instead of being + * grouped by a guessed folder. */ export function sessionBelongsToWorkspaceNavRow( - session: Pick, + session: SessionNavigationOwner, workspaceId?: string, ): boolean { if (!workspaceId) return false; - return (session.workspaceId ?? session.projectWorkspaceId) === workspaceId; + return sessionOwningWorkspaceId(session) === workspaceId; +} + +/** + * Owning workspace ID every session-scoped host request is addressed with. + * + * Persistence, session state, and the session's own configuration catalogs all + * belong to the workspace that owns the session, never to its execution + * directory. An isolated session's worktree record is created on demand for + * execution and is normally not an open workspace, so a request addressed with + * that record is rejected outright while the same request addressed with the + * owning project resolves to the identical session directory. + */ +export function requireSessionOwningWorkspaceId(session: SessionNavigationOwner): string { + const workspaceId = sessionOwningWorkspaceId(session); + if (!workspaceId) throw new Error('Session workspace ID is unavailable'); + return workspaceId; } export function getSessionSortTimestamp(session: Pick): number { diff --git a/src/web-ui/src/flow_chat/utils/sessionWorkspace.ts b/src/web-ui/src/flow_chat/utils/sessionWorkspace.ts index 68c153f3d4..5965312559 100644 --- a/src/web-ui/src/flow_chat/utils/sessionWorkspace.ts +++ b/src/web-ui/src/flow_chat/utils/sessionWorkspace.ts @@ -79,14 +79,19 @@ export function requireSessionProjectWorkspacePath( return path; } -/** Workspace identity for persistence and routing; `undefined` only for pre-ID sessions. */ +/** + * Workspace identity of the directory the session executes in, which is a + * linked worktree for an isolated session. Session state that belongs to the + * owning project is addressed with `sessionOwningWorkspaceId` instead. + * `undefined` only for pre-ID sessions. + */ export function sessionWorkspaceId( session: Partial> | undefined, ): string | undefined { return session?.workspaceId || session?.config?.workspaceId || undefined; } -/** Workspace identity for persistence and routing. Paths are IO projections only. */ +/** Execution workspace identity; `requireSessionOwningWorkspaceId` owns session state. */ export function requireSessionWorkspaceId( session: Partial>, ): string { diff --git a/src/web-ui/src/flow_chat/utils/sessionWorktree.test.ts b/src/web-ui/src/flow_chat/utils/sessionWorktree.test.ts index bbd94e5371..609f748b47 100644 --- a/src/web-ui/src/flow_chat/utils/sessionWorktree.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionWorktree.test.ts @@ -5,6 +5,7 @@ import { isSessionWorktreeBindingLocked, sessionWorktreeMaterializationPlan, sessionWorktreeBindingSubscriptionKey, + sessionWorktreeRootPath, } from './sessionWorktree'; function session(overrides: Partial = {}): Session { @@ -166,3 +167,47 @@ describe('session worktree control', () => { expect(rebound).not.toBe(initial); }); }); + +describe('worktree execution root', () => { + it('reports the worktree directory of an isolated session', () => { + expect(sessionWorktreeRootPath(session({ + workspaceId: 'worktree-ws', + projectWorkspaceId: 'project-ws', + workspacePath: '/worktrees/wt-1', + config: { + executionTarget: { kind: 'managedWorktree', worktreeId: 'wt-1', rootPath: '/worktrees/wt-1' }, + }, + }))).toBe('/worktrees/wt-1'); + }); + + it('falls back to the execution path for a legacy target without a root', () => { + const legacy = session({ + workspacePath: '/worktrees/wt-2', + config: { + // Persisted targets written before `rootPath` was required. + executionTarget: { kind: 'existingWorktree', worktreeId: 'wt-2' }, + } as Session['config'], + }); + expect(sessionWorktreeRootPath(legacy)).toBe('/worktrees/wt-2'); + }); + + it('reports nothing when the isolated target carries no usable directory', () => { + expect(sessionWorktreeRootPath(session({ + workspacePath: ' ', + config: { + executionTarget: { kind: 'existingWorktree', worktreeId: 'wt-3', rootPath: ' ' }, + }, + }))).toBeUndefined(); + }); + + it('reports nothing for a session that runs in its project root', () => { + expect(sessionWorktreeRootPath(session({ workspacePath: '/repo' }))).toBeUndefined(); + expect(sessionWorktreeRootPath(session({ + workspacePath: '/repo', + config: { + executionTarget: { kind: 'local', rootPath: '/repo' }, + worktreeIsolationRequested: true, + }, + }))).toBeUndefined(); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/sessionWorktree.ts b/src/web-ui/src/flow_chat/utils/sessionWorktree.ts index 30c9e0d7f2..e298713632 100644 --- a/src/web-ui/src/flow_chat/utils/sessionWorktree.ts +++ b/src/web-ui/src/flow_chat/utils/sessionWorktree.ts @@ -1,5 +1,6 @@ import type { Session } from '../types/flow-chat'; import { isProjectedSessionEmpty } from './flowChatTurnIdentity'; +import { isWorktreeIsolatedSession } from './sessionOrdering'; import { sessionProjectWorkspaceId, sessionProjectWorkspacePath } from './sessionWorkspace'; type SessionWorktreeFacts = Pick< @@ -40,6 +41,25 @@ export function isSessionWorktreeIsolationEnabled( ?? isSessionWorktreeMaterialized(session); } +type SessionWorktreeRootFacts = Pick< + SessionWorktreeFacts, + 'workspaceId' | 'projectWorkspaceId' | 'config' | 'workspacePath' +>; + +/** + * Directory in which a worktree-isolated session actually runs, or `undefined` + * when the session runs in its project root. + * + * Every surface that starts work for the session (navigation badge, tooltip, + * terminal cwd) reads this one fact, so none of them can disagree about where + * the worktree is. + */ +export function sessionWorktreeRootPath(session: SessionWorktreeRootFacts): string | undefined { + if (!isWorktreeIsolatedSession(session)) return undefined; + const rootPath = (session.config.executionTarget?.rootPath ?? session.workspacePath ?? '').trim(); + return rootPath || undefined; +} + export interface SessionWorktreeMaterializationPlan { enabled: boolean; /** Owning project workspace ID; the path below is only the Git IO operand. */ diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index a1d4fb003d..d9cf93f989 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -380,6 +380,9 @@ "parentSession": "Parent session", "childSourceWithoutTurn": "From {{parentTitle}}", "childSourceWithTurn": "From {{parentTitle}} · Turn {{turnIndex}}", + "worktreeTooltip": "Runs in an isolated worktree at {{path}}", + "openWorktreeWorkspace": "Open worktree workspace", + "openWorktreeWorkspaceFailed": "Could not open the worktree folder as a workspace.", "reviewRunning": "Reviewing", "deepReviewRunning": "Reviewing", "unreadCompleted": "Completed — unread", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 52cf0feaec..a4eee79465 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -380,6 +380,9 @@ "parentSession": "父会话", "childSourceWithoutTurn": "来自 {{parentTitle}}", "childSourceWithTurn": "来自 {{parentTitle}} · 第 {{turnIndex}} 轮", + "worktreeTooltip": "正在隔离的 worktree 中执行:{{path}}", + "openWorktreeWorkspace": "打开 worktree 工作区", + "openWorktreeWorkspaceFailed": "无法将 worktree 目录作为工作区打开。", "reviewRunning": "审核中", "deepReviewRunning": "审核中", "unreadCompleted": "已完成 — 未读", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index f016a99eb5..0c9fb19905 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -380,6 +380,9 @@ "parentSession": "父會話", "childSourceWithoutTurn": "來自 {{parentTitle}}", "childSourceWithTurn": "來自 {{parentTitle}} · 第 {{turnIndex}} 輪", + "worktreeTooltip": "正在隔離的 worktree 中執行:{{path}}", + "openWorktreeWorkspace": "開啟 worktree 工作區", + "openWorktreeWorkspaceFailed": "無法將 worktree 目錄作為工作區開啟。", "reviewRunning": "審核中", "deepReviewRunning": "審核中", "unreadCompleted": "已完成 — 未讀",