From 6fb18a4fbce4a4e7e93e3176f84e58bb6eea5946 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 14:45:58 +0800 Subject: [PATCH 1/6] fix(web-ui): keep worktree sessions in their project and open terminals there A session that executes in a managed worktree is stamped with the worktree's own workspace record. That record is registered for execution but not opened, so grouping sessions by that raw workspace ID dropped the session out of every navigation group as soon as its first message created the worktree. Sessions are now attributed to their owning project workspace ID instead of a path, the row carries a worktree badge with the execution directory, and that directory can be opened as a workspace from the row menu. Terminals opened for such a session's project still belong to the project, but their cwd now follows the session's execution root, so a hand-started shell no longer runs git and build commands in the main working tree while the agent edits the worktree. An explicit directory, such as the file explorer's open terminal here, still wins. --- .../sections/sessions/SessionsSection.scss | 22 ++++ .../sections/sessions/SessionsSection.tsx | 66 ++++++++++- .../sessions/sessionWorktreeRow.test.ts | 91 +++++++++++++++ .../sections/workspaces/WorkspaceItem.tsx | 3 +- .../NavPanel/workspaceSessionView.ts | 4 +- .../sessionTerminalDirectoryWiring.test.ts | 35 ++++++ .../hooks/useSessionTerminalDirectory.test.ts | 107 ++++++++++++++++++ .../app/hooks/useSessionTerminalDirectory.ts | 50 ++++++++ .../app/scenes/shell/hooks/useShellEntries.ts | 6 + .../scenes/shell/hooks/useTerminalSessions.ts | 8 +- .../terminal/TerminalActionBridge.test.tsx | 44 +++++++ .../scenes/terminal/TerminalActionBridge.tsx | 11 +- .../flow_chat/services/BtwThreadService.ts | 5 + .../flow-chat-manager/EventHandlerModule.ts | 5 + .../local/LocalSessionDriver.ts | 1 + .../src/flow_chat/store/FlowChatStore.test.ts | 67 +++++++++++ .../src/flow_chat/store/FlowChatStore.ts | 10 +- .../flow_chat/utils/sessionOrdering.test.ts | 38 ++++++- .../src/flow_chat/utils/sessionOrdering.ts | 42 +++++-- .../flow_chat/utils/sessionWorktree.test.ts | 45 ++++++++ .../src/flow_chat/utils/sessionWorktree.ts | 20 ++++ src/web-ui/src/locales/en-US/common.json | 4 + src/web-ui/src/locales/zh-CN/common.json | 4 + src/web-ui/src/locales/zh-TW/common.json | 4 + 24 files changed, 671 insertions(+), 21 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts create mode 100644 src/web-ui/src/app/hooks/sessionTerminalDirectoryWiring.test.ts create mode 100644 src/web-ui/src/app/hooks/useSessionTerminalDirectory.test.ts create mode 100644 src/web-ui/src/app/hooks/useSessionTerminalDirectory.ts 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..70d7ad7ce7 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,28 @@ } } + &__inline-item-worktree-badge { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 3px; + height: 14px; + padding: 0 5px; + border: 1px dashed var(--openbitfun-color-border-subtle); + border-radius: 999px; + background: var(--openbitfun-color-action-quiet-hover); + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-micro-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + white-space: nowrap; + + .openbitfun-nav-panel__inline-item-worktree-icon { + inline-size: var(--openbitfun-control-icon-size2xs); + block-size: var(--openbitfun-control-icon-size2xs); + opacity: var(--openbitfun-opacity-muted); + } + } + &__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..572a9b6e89 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 @@ -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,7 @@ 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 { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { @@ -32,8 +33,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 +240,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 @@ -1268,6 +1271,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 +1568,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 +1608,7 @@ const SessionsSection: React.FC = ({ showAssistantInTooltip || isChildSession || showBackgroundSubagentActivity || + worktreeIsolated || isDispatched; const tooltipContent = showRichTooltip ? (
@@ -1602,6 +1638,11 @@ const SessionsSection: React.FC = ({ })}
) : null} + {worktreeIsolated ? ( +
+ {t('nav.sessions.worktreeTooltip', { path: worktreeRootPath })} +
+ ) : null} {isDispatched ? (
{dispatchPresentation?.summary} @@ -1735,6 +1776,16 @@ const SessionsSection: React.FC = ({ {dispatchPresentation?.badgeLabel} ) : null} + {worktreeIsolated ? ( + + + {t('nav.sessions.worktreeBadge')} + + ) : null} {reviewActivityKind ? ( @@ -1925,6 +1976,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('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.worktreeBadge).toBeTruthy(); + expect(sessions.openWorktreeWorkspace).toBeTruthy(); + expect(sessions.openWorktreeWorkspaceFailed).toBeTruthy(); + // 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/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/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/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 64fd224a6d..e8e80f0d8b 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 @@ -407,6 +407,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..24f1876ff3 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,73 @@ 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', + }); + }); }); describe('FlowChatStore dispatch observer boundaries', () => { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index e90215e373..5e374b9dbd 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -4253,6 +4253,7 @@ export class FlowChatStore { reviewTargetEvidence?: Session['reviewTargetEvidence']; reviewTargetFilePaths?: Session['reviewTargetFilePaths']; projectWorkspacePath?: string; + projectWorkspaceId?: string; executionTarget?: Session['config']['executionTarget']; workspaceId?: string; }, @@ -4286,6 +4287,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 +4302,7 @@ export class FlowChatStore { workspacePath, projectWorkspacePath: meta?.projectWorkspacePath, workspaceId: meta?.workspaceId, + projectWorkspaceId: meta?.projectWorkspaceId, remoteConnectionId, remoteSshHost, parentSessionId: relationship.parentSessionId, @@ -4667,7 +4670,9 @@ 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. */ public updateSessionExecutionTarget( sessionId: string, @@ -4675,6 +4680,7 @@ export class FlowChatStore { workspacePath: string; projectWorkspacePath: string; workspaceId?: string; + projectWorkspaceId?: string; executionTarget: Session['config']['executionTarget']; }, ): void { @@ -4688,11 +4694,13 @@ export class FlowChatStore { workspacePath: binding.workspacePath, projectWorkspacePath: binding.projectWorkspacePath, workspaceId: binding.workspaceId ?? session.workspaceId, + projectWorkspaceId: binding.projectWorkspaceId ?? session.projectWorkspaceId, config: { ...session.config, workspacePath: binding.workspacePath, projectWorkspacePath: binding.projectWorkspacePath, workspaceId: binding.workspaceId ?? session.config.workspaceId, + projectWorkspaceId: binding.projectWorkspaceId ?? session.config.projectWorkspaceId, executionTarget: binding.executionTarget, }, lastActiveAt: Date.now(), 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..cdb22faf1d 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts @@ -104,16 +104,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); diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts index be667adc2f..a2059cb7e8 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts @@ -1,18 +1,46 @@ 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'; +} + +/** + * 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 — `projectWorkspaceId` is the identity the worktree cannot outlive. + * + * `projectWorkspaceId` is a legacy fallback for records created before + * execution-workspace stamping, and it must still never widen membership to + * sibling worktrees: only the session's own owning project matches. */ export function sessionBelongsToWorkspaceNavRow( - session: Pick, + session: SessionNavigationOwner, workspaceId?: string, ): boolean { if (!workspaceId) return false; - return (session.workspaceId ?? session.projectWorkspaceId) === workspaceId; + const ownerWorkspaceId = isWorktreeIsolatedSession(session) + ? (session.projectWorkspaceId ?? session.workspaceId) + : (session.workspaceId ?? session.projectWorkspaceId); + return ownerWorkspaceId === workspaceId; } export function getSessionSortTimestamp(session: Pick): number { 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..175f9f2283 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -380,6 +380,10 @@ "parentSession": "Parent session", "childSourceWithoutTurn": "From {{parentTitle}}", "childSourceWithTurn": "From {{parentTitle}} · Turn {{turnIndex}}", + "worktreeBadge": "Worktree", + "worktreeTooltip": "Runs in an isolated worktree at {{path}}", + "openWorktreeWorkspace": "Open worktree folder as a 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..12458003f8 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -380,6 +380,10 @@ "parentSession": "父会话", "childSourceWithoutTurn": "来自 {{parentTitle}}", "childSourceWithTurn": "来自 {{parentTitle}} · 第 {{turnIndex}} 轮", + "worktreeBadge": "工作树", + "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..cce5884eb4 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -380,6 +380,10 @@ "parentSession": "父會話", "childSourceWithoutTurn": "來自 {{parentTitle}}", "childSourceWithTurn": "來自 {{parentTitle}} · 第 {{turnIndex}} 輪", + "worktreeBadge": "工作樹", + "worktreeTooltip": "正在隔離的 worktree 中執行:{{path}}", + "openWorktreeWorkspace": "將 worktree 目錄作為工作區開啟", + "openWorktreeWorkspaceFailed": "無法將 worktree 目錄作為工作區開啟。", "reviewRunning": "審核中", "deepReviewRunning": "審核中", "unreadCompleted": "已完成 — 未讀", From 3d7ef141b6a9d94c08e8d7644203cd73a4f79954 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 15:14:46 +0800 Subject: [PATCH 2/6] fix(web-ui): activate the workspace that owns a worktree session Returning to a session that executes in a managed worktree reported that its workspace was not open and refused to switch to it. Activation resolved the session's execution workspace, which for such a session is the worktree record registered on demand during its first message and normally not open, while the session is listed under its project. Session ownership is now one shared identity, `sessionOwningWorkspaceId`, used by the navigation list, the workspace bootstrap, the scene tab key, session activation, the scene-active check, and the companion jump. None of them can derive a different owner any more. A worktree-isolated session is owned by its project, so the project is what gets activated and its scene counts as active. A session created while browsing a linked worktree as its own workspace is still owned by that workspace, even though the worktree's project ID points at the main checkout. --- .../openAgentCompanionSession.test.ts | 38 +++++++++++-- .../app/services/openAgentCompanionSession.ts | 23 +++----- .../services/sessionSceneLifecycle.test.ts | 46 ++++++++++++++-- .../src/app/services/sessionSceneLifecycle.ts | 13 ++--- .../app/services/sessionSceneTarget.test.ts | 35 ++++++++---- .../src/app/services/sessionSceneTarget.ts | 30 ++++------- .../services/sessionActivation.test.ts | 54 +++++++++++++++++-- .../flow_chat/services/sessionActivation.ts | 4 +- .../flow_chat/utils/sessionOrdering.test.ts | 17 ++++++ .../src/flow_chat/utils/sessionOrdering.ts | 38 +++++++++---- 10 files changed, 225 insertions(+), 73 deletions(-) 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..dea70c69b8 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,43 @@ 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(); }); }); diff --git a/src/web-ui/src/app/services/sessionSceneTarget.ts b/src/web-ui/src/app/services/sessionSceneTarget.ts index 10f214b3a4..1413277ce1 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,24 +28,6 @@ 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, 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/utils/sessionOrdering.test.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts index cdb22faf1d..bca4b429df 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts @@ -7,6 +7,7 @@ import { getSessionMetadataSortTimestamp, getSessionSortTimestamp, sessionBelongsToWorkspaceNavRow, + sessionOwningWorkspaceId, } from './sessionOrdering'; function createSession(overrides: Partial = {}): Session { @@ -157,4 +158,20 @@ 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(); + }); }); diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts index a2059cb7e8..b6da9192ec 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts @@ -5,7 +5,7 @@ import type { SessionMetadata } from '@/shared/types/session-history'; * optional so metadata-only and legacy call sites keep working. */ type SessionNavigationOwner = Pick & { - config?: Pick; + config?: Pick; }; /** @@ -19,6 +19,30 @@ export function isWorktreeIsolatedSession(session: SessionNavigationOwner): bool 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. * @@ -26,21 +50,17 @@ export function isWorktreeIsolatedSession(session: SessionNavigationOwner): bool * 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 — `projectWorkspaceId` is the identity the worktree cannot outlive. + * owns it — the project identity is the one the worktree cannot outlive. * - * `projectWorkspaceId` is a legacy fallback for records created before - * execution-workspace stamping, and it must still never widen membership to - * sibling worktrees: only the session's own owning project matches. + * Legacy records without any workspace identity stay unresolved instead of being + * grouped by a guessed folder. */ export function sessionBelongsToWorkspaceNavRow( session: SessionNavigationOwner, workspaceId?: string, ): boolean { if (!workspaceId) return false; - const ownerWorkspaceId = isWorktreeIsolatedSession(session) - ? (session.projectWorkspaceId ?? session.workspaceId) - : (session.workspaceId ?? session.projectWorkspaceId); - return ownerWorkspaceId === workspaceId; + return sessionOwningWorkspaceId(session) === workspaceId; } export function getSessionSortTimestamp(session: Pick): number { From fa840d51b971b549b530baf4f94e09f5085d16de Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 15:50:00 +0800 Subject: [PATCH 3/6] fix(web-ui): address worktree session storage through its owning project Every persisted-session command takes a workspace ID that the backend resolves into a session directory, and the backend refuses an ID whose workspace record is not an open workspace. A managed worktree record is created on demand for one session, is normally not open, and resolves to the same session directory as the project it belongs to, so addressing it made restore_session_view, load_session_turns, save_session_turn, and delete_session fail for a worktree session while the run itself worked. Route those calls through one owning-project selector so the shell never sends an execution identity for session storage, and keep the full-history hydration request key on the same identity the store already dedupes with. --- .../app/services/sessionSceneTarget.test.ts | 9 ++ .../src/app/services/sessionSceneTarget.ts | 4 +- .../src/flow_chat/store/FlowChatStore.test.ts | 87 +++++++++++++++++++ .../src/flow_chat/store/FlowChatStore.ts | 57 ++++++++---- 4 files changed, 139 insertions(+), 18 deletions(-) diff --git a/src/web-ui/src/app/services/sessionSceneTarget.test.ts b/src/web-ui/src/app/services/sessionSceneTarget.test.ts index dea70c69b8..ee12cf90cf 100644 --- a/src/web-ui/src/app/services/sessionSceneTarget.test.ts +++ b/src/web-ui/src/app/services/sessionSceneTarget.test.ts @@ -87,4 +87,13 @@ describe('session workspace ownership', () => { 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 1413277ce1..4148a78214 100644 --- a/src/web-ui/src/app/services/sessionSceneTarget.ts +++ b/src/web-ui/src/app/services/sessionSceneTarget.ts @@ -34,7 +34,9 @@ export function resolveSessionSceneTarget( 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/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 24f1876ff3..dccc6cf269 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -384,6 +384,41 @@ describe('FlowChatStore lazy worktree preference', () => { 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', () => { @@ -1720,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 5e374b9dbd..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; @@ -4672,7 +4684,8 @@ export class FlowChatStore { * Apply a backend session rebind (worktree isolation toggled on or off). * 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. + * created without, and the workspace a session moves away from supplies it + * when the binding reports none. */ public updateSessionExecutionTarget( sessionId: string, @@ -4689,18 +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: binding.projectWorkspaceId ?? session.projectWorkspaceId, + projectWorkspaceId: owningProjectWorkspaceId, config: { ...session.config, workspacePath: binding.workspacePath, projectWorkspacePath: binding.projectWorkspacePath, workspaceId: binding.workspaceId ?? session.config.workspaceId, - projectWorkspaceId: binding.projectWorkspaceId ?? session.config.projectWorkspaceId, + projectWorkspaceId: owningProjectWorkspaceId, executionTarget: binding.executionTarget, }, lastActiveAt: Date.now(), @@ -5207,7 +5230,7 @@ export class FlowChatStore { await agentAPI.deleteSession( id, - requireSessionWorkspaceId(sess!) + this.sessionHistoryWorkspaceId(id) ); }) ); @@ -7009,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 }); } @@ -7845,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, @@ -8105,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, @@ -8392,7 +8415,7 @@ export class FlowChatStore { try { const restoredPromise = agentAPI.restoreSessionWithTurns( sessionId, - this.sessionWorkspaceId(sessionId), + this.sessionHistoryWorkspaceId(sessionId), sessionTraceId, options?.includeInternal, ); @@ -8420,7 +8443,7 @@ export class FlowChatStore { const restoredSessionPromise = agentAPI.restoreSession( sessionId, - this.sessionWorkspaceId(sessionId), + this.sessionHistoryWorkspaceId(sessionId), sessionTraceId, options?.includeInternal, ); @@ -8436,7 +8459,7 @@ export class FlowChatStore { try { const restoredPromise = agentAPI.restoreSessionView( sessionId, - this.sessionWorkspaceId(sessionId), + this.sessionHistoryWorkspaceId(sessionId), sessionTraceId, options?.includeInternal, historicalSessionInitialTailTurnCount(remote), @@ -8518,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, @@ -8762,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, From 71fab438e57be164689954d304c2248400629c61 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 16:10:19 +0800 Subject: [PATCH 4/6] fix(web-ui): address worktree session state through its owning project A session isolated in a linked worktree carries that worktree's workspace ID as its execution identity. Persistence, session state, and the session's own configuration catalogs were addressed with the same ID, but a managed worktree record exists for execution and is normally not an open workspace, so the host rejected those requests outright and the composer could not resolve modes, subagents, or external sources for the session. Address session storage, activity, archive, rename, compact, fork, delete, export, and usage-report calls, plus the composer's workspace configuration and permission-mode requests, with the owning project ID. Requests that describe where the session executes - session creation, turn dispatch, git state, and the workspace strip - keep the execution ID, which the strip now receives explicitly. --- .../sections/sessions/SessionsSection.tsx | 4 +- .../src/flow_chat/components/ChatInput.tsx | 58 ++++++++++++++----- .../modern/ModernFlowChatContainer.tsx | 4 +- .../ReviewActionBarPersistenceService.ts | 4 +- .../PersistenceModule.test.ts | 24 +++++++- .../flow-chat-manager/PersistenceModule.ts | 8 +-- .../flow-chat-manager/SessionModule.ts | 9 +-- .../flow_chat/services/usageReportService.ts | 4 +- .../local/LocalSessionDriver.ts | 13 +++-- .../flow_chat/utils/sessionOrdering.test.ts | 15 +++++ .../src/flow_chat/utils/sessionOrdering.ts | 16 +++++ .../src/flow_chat/utils/sessionWorkspace.ts | 9 ++- 12 files changed, 128 insertions(+), 40 deletions(-) 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 572a9b6e89..2d60ee9cac 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. * @@ -1211,7 +1211,7 @@ const SessionsSection: React.FC = ({ { sessionId: session.sessionId, title: resolveSessionTitle(session), - workspaceId: requireSessionWorkspaceId(session), + workspaceId: requireSessionOwningWorkspaceId(session), }, scope ); 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/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/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/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 e8e80f0d8b..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, }); 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 bca4b429df..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,6 +6,7 @@ import { compareSessionsForNavStable, getSessionMetadataSortTimestamp, getSessionSortTimestamp, + requireSessionOwningWorkspaceId, sessionBelongsToWorkspaceNavRow, sessionOwningWorkspaceId, } from './sessionOrdering'; @@ -174,4 +175,18 @@ describe('sessionOrdering', () => { .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 b6da9192ec..3235e3d912 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts @@ -63,6 +63,22 @@ export function sessionBelongsToWorkspaceNavRow( 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 { return session.lastFinishedAt ?? session.createdAt; } 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 { From 82dc93a75475a8808e91fad5f7730de3f42e9926 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 17:18:05 +0800 Subject: [PATCH 5/6] fix(web-ui): silence an empty session expand toggle on linked worktrees A linked worktree row stores its sessions in the main workspace session root, so the metadata page total also counted the project sessions and the row offered "show more +N" for rows its own list can never reveal. Size the expand toggle from the rows the row actually owns when the scoped workspace is a linked worktree. The worktree marker beside a session title is now icon-only, and the menu entry that opens the worktree directory as a workspace uses the shorter "Open worktree workspace" copy that fits the menu width. --- .../sections/sessions/SessionsSection.scss | 19 +++++----- .../sections/sessions/SessionsSection.tsx | 38 ++++++++++++++----- .../sessions/sessionWorktreeRow.test.ts | 33 +++++++++++++++- src/web-ui/src/locales/en-US/common.json | 3 +- src/web-ui/src/locales/zh-CN/common.json | 3 +- src/web-ui/src/locales/zh-TW/common.json | 3 +- 6 files changed, 73 insertions(+), 26 deletions(-) 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 70d7ad7ce7..7c12dc7f59 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 @@ -363,23 +363,24 @@ &__inline-item-worktree-badge { flex: 0 0 auto; - display: inline-flex; - align-items: center; - gap: 3px; - height: 14px; - padding: 0 5px; + // Icon-only circle with the same 14px height as the neighbouring row badges, + // so the marker stays scannable without carrying a label beside the title. + display: inline-grid; + place-items: center; + inline-size: 14px; + block-size: 14px; border: 1px dashed var(--openbitfun-color-border-subtle); border-radius: 999px; background: var(--openbitfun-color-action-quiet-hover); color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-micro-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - white-space: nowrap; + // Kill the inline strut so the icon stays optically centered in the circle. + line-height: 0; .openbitfun-nav-panel__inline-item-worktree-icon { + // Same 2xs glyph as the neighbouring cron badge, at the badge's own + // secondary colour so the marker stays legible without a label. inline-size: var(--openbitfun-control-icon-size2xs); block-size: var(--openbitfun-control-icon-size2xs); - opacity: var(--openbitfun-opacity-muted); } } 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 2d60ee9cac..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 @@ -19,6 +19,7 @@ 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 { @@ -845,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. @@ -1777,13 +1794,14 @@ const SessionsSection: React.FC = ({ ) : null} {worktreeIsolated ? ( + // Icon-only marker: the badge sits next to the title, where a + // label competes with it. The tooltip carries the worktree path. - {t('nav.sessions.worktreeBadge')} ) : null} {reviewActivityKind ? ( diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts index 2ffa37873e..dd9456fafe 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts @@ -37,6 +37,36 @@ describe('worktree isolated session row', () => { 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: 14px;/, + ); + }); + + 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, @@ -81,9 +111,10 @@ describe('worktree row copy', () => { ) as { nav: { sessions: Record } }; const sessions = catalog.nav.sessions; - expect(sessions.worktreeBadge).toBeTruthy(); 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/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 175f9f2283..d9cf93f989 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -380,9 +380,8 @@ "parentSession": "Parent session", "childSourceWithoutTurn": "From {{parentTitle}}", "childSourceWithTurn": "From {{parentTitle}} · Turn {{turnIndex}}", - "worktreeBadge": "Worktree", "worktreeTooltip": "Runs in an isolated worktree at {{path}}", - "openWorktreeWorkspace": "Open worktree folder as a workspace", + "openWorktreeWorkspace": "Open worktree workspace", "openWorktreeWorkspaceFailed": "Could not open the worktree folder as a workspace.", "reviewRunning": "Reviewing", "deepReviewRunning": "Reviewing", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 12458003f8..a4eee79465 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -380,9 +380,8 @@ "parentSession": "父会话", "childSourceWithoutTurn": "来自 {{parentTitle}}", "childSourceWithTurn": "来自 {{parentTitle}} · 第 {{turnIndex}} 轮", - "worktreeBadge": "工作树", "worktreeTooltip": "正在隔离的 worktree 中执行:{{path}}", - "openWorktreeWorkspace": "将 worktree 目录作为工作区打开", + "openWorktreeWorkspace": "打开 worktree 工作区", "openWorktreeWorkspaceFailed": "无法将 worktree 目录作为工作区打开。", "reviewRunning": "审核中", "deepReviewRunning": "审核中", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index cce5884eb4..0c9fb19905 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -380,9 +380,8 @@ "parentSession": "父會話", "childSourceWithoutTurn": "來自 {{parentTitle}}", "childSourceWithTurn": "來自 {{parentTitle}} · 第 {{turnIndex}} 輪", - "worktreeBadge": "工作樹", "worktreeTooltip": "正在隔離的 worktree 中執行:{{path}}", - "openWorktreeWorkspace": "將 worktree 目錄作為工作區開啟", + "openWorktreeWorkspace": "開啟 worktree 工作區", "openWorktreeWorkspaceFailed": "無法將 worktree 目錄作為工作區開啟。", "reviewRunning": "審核中", "deepReviewRunning": "審核中", From c94083953f4d7e00c7b383622ecb1eee09e3c3ca Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 17:23:48 +0800 Subject: [PATCH 6/6] fix(web-ui): size the worktree session marker at a normal icon scale The icon-only worktree marker next to a session title used the 8px 2xs glyph inside a 14px circle; at that scale the worktree glyph was hard to read. Use the same 16px circle and 12px glyph as the neighbouring background-subagent badge. --- .../sections/sessions/SessionsSection.scss | 14 ++++++-------- .../sections/sessions/sessionWorktreeRow.test.ts | 6 +++++- 2 files changed, 11 insertions(+), 9 deletions(-) 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 7c12dc7f59..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 @@ -363,12 +363,12 @@ &__inline-item-worktree-badge { flex: 0 0 auto; - // Icon-only circle with the same 14px height as the neighbouring row badges, - // so the marker stays scannable without carrying a label beside the title. + // 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: 14px; - block-size: 14px; + 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); @@ -377,10 +377,8 @@ line-height: 0; .openbitfun-nav-panel__inline-item-worktree-icon { - // Same 2xs glyph as the neighbouring cron badge, at the badge's own - // secondary colour so the marker stays legible without a label. - inline-size: var(--openbitfun-control-icon-size2xs); - block-size: var(--openbitfun-control-icon-size2xs); + inline-size: var(--openbitfun-control-icon-size-xs); + block-size: var(--openbitfun-control-icon-size-xs); } } diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts index dd9456fafe..5f9765e50a 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts @@ -45,7 +45,11 @@ describe('worktree isolated session row', () => { /inline-item-worktree-badge"[\s\S]{0,400}?\s*<\/span>/, ); expect(sessionsSectionStyles).toMatch( - /&__inline-item-worktree-badge \{[\s\S]*?inline-size: 14px;/, + /&__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\);/, ); });