Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,27 @@
}
}

&__inline-item-worktree-badge {
flex: 0 0 auto;
// Icon-only circle sized like the neighbouring background-subagent badge, so
// the marker reads at a normal icon size without a label beside the title.
display: inline-grid;
place-items: center;
inline-size: var(--openbitfun-control-icon-size-md);
block-size: var(--openbitfun-control-icon-size-md);
border: 1px dashed var(--openbitfun-color-border-subtle);
border-radius: 999px;
background: var(--openbitfun-color-action-quiet-hover);
color: var(--openbitfun-color-content-secondary);
// Kill the inline strut so the icon stays optically centered in the circle.
line-height: 0;

.openbitfun-nav-panel__inline-item-worktree-icon {
inline-size: var(--openbitfun-control-icon-size-xs);
block-size: var(--openbitfun-control-icon-size-xs);
}
}

&__inline-item-background-subagent-badge {
flex: 0 0 auto;
position: relative;
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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';
Expand All @@ -18,6 +18,8 @@ import type { FlowChatState, Session } from '../../../../../flow_chat/types/flow
import { useSceneStore } from '../../../../stores/sceneStore';
import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext';
import { createLogger } from '@/shared/utils/logger';
import { isSamePath } from '@/shared/utils/pathUtils';
import { isLinkedWorktreeWorkspace } from '@/shared/types/global-state';
import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost';
import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores';
import {
Expand All @@ -32,8 +34,10 @@ import {
import { recordHistorySessionDiagnosticEvent } from '@/flow_chat/services/historySessionDiagnostics';
import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata';
import {
isWorktreeIsolatedSession,
sessionBelongsToWorkspaceNavRow,
} from '@/flow_chat/utils/sessionOrdering';
import { sessionWorktreeRootPath } from '@/flow_chat/utils/sessionWorktree';
import {
compareWorkspaceNavSessions,
DEFAULT_WORKSPACE_SESSION_FILTERS,
Expand Down Expand Up @@ -237,7 +241,7 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
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
Expand Down Expand Up @@ -842,16 +846,32 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
});
}, [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.
Expand Down Expand Up @@ -1208,7 +1228,7 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
{
sessionId: session.sessionId,
title: resolveSessionTitle(session),
workspaceId: requireSessionWorkspaceId(session),
workspaceId: requireSessionOwningWorkspaceId(session),
},
scope
);
Expand Down Expand Up @@ -1268,6 +1288,36 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
[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();
Expand Down Expand Up @@ -1535,6 +1585,8 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
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)
Expand Down Expand Up @@ -1573,6 +1625,7 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
showAssistantInTooltip ||
isChildSession ||
showBackgroundSubagentActivity ||
worktreeIsolated ||
isDispatched;
const tooltipContent = showRichTooltip ? (
<div className="openbitfun-nav-panel__inline-item-tooltip">
Expand Down Expand Up @@ -1602,6 +1655,11 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
})}
</div>
) : null}
{worktreeIsolated ? (
<div className="openbitfun-nav-panel__inline-item-tooltip-meta">
{t('nav.sessions.worktreeTooltip', { path: worktreeRootPath })}
</div>
) : null}
{isDispatched ? (
<div className="openbitfun-nav-panel__inline-item-tooltip-meta">
{dispatchPresentation?.summary}
Expand Down Expand Up @@ -1735,6 +1793,17 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
{dispatchPresentation?.badgeLabel}
</OverflowText></span>
) : null}
{worktreeIsolated ? (
// Icon-only marker: the badge sits next to the title, where a
// label competes with it. The tooltip carries the worktree path.
<span
className="openbitfun-nav-panel__inline-item-worktree-badge"
title={t('nav.sessions.worktreeTooltip', { path: worktreeRootPath })}
aria-label={t('nav.sessions.worktreeTooltip', { path: worktreeRootPath })}
>
<FolderGit2 className="openbitfun-nav-panel__inline-item-worktree-icon" aria-hidden />
</span>
) : null}
{reviewActivityKind ? (
<span className="openbitfun-nav-panel__inline-item-review-badge">
<Loader2 className="openbitfun-nav-panel__inline-item-review-icon" aria-hidden />
Expand Down Expand Up @@ -1925,6 +1994,17 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
>
<span>{t('nav.sessions.manage')}</span>
</MenuItem>
{worktreeIsolated && worktreeRootPath ? (
<MenuItem
type="button"
leading={<Icon glyph={FolderGit2} size="sm" />}
onClick={e => { void handleOpenWorktreeWorkspace(e, worktreeRootPath); }}
data-testid="nav-session-menu-open-worktree-workspace"
data-session-id={session.sessionId}
>
<span>{t('nav.sessions.openWorktreeWorkspace')}</span>
</MenuItem>
) : null}
<MenuItem
type="button"
tone="danger"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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 sessionsSection = read('./SessionsSection.tsx');
const sessionsSectionStyles = read('./SessionsSection.scss');

describe('worktree isolated session row', () => {
it('derives the worktree fact from the shared session ordering helper', () => {
expect(sessionsSection).toMatch(
/import \{[^}]*isWorktreeIsolatedSession[^}]*\} from '@\/flow_chat\/utils\/sessionOrdering'/,
);
expect(sessionsSection).toContain('const worktreeIsolated = isWorktreeIsolatedSession(session);');
// The row must never re-derive the fact from the execution target itself.
expect(sessionsSection).not.toContain('session.config.executionTarget?.kind !==');
});

it('resolves the worktree directory the session runs in through the shared helper', () => {
expect(sessionsSection).toMatch(
/import \{ sessionWorktreeRootPath \} from '@\/flow_chat\/utils\/sessionWorktree'/,
);
expect(sessionsSection).toContain('const worktreeRootPath = sessionWorktreeRootPath(session) ?? \'\';');
});

it('marks the row with an accessible worktree badge', () => {
expect(sessionsSection).toContain('openbitfun-nav-panel__inline-item-worktree-badge');
expect(sessionsSection).toMatch(
/aria-label=\{t\('nav\.sessions\.worktreeTooltip', \{ path: worktreeRootPath \}\)\}/,
);
expect(sessionsSectionStyles).toContain('&__inline-item-worktree-badge {');
});

it('keeps the worktree badge icon-only', () => {
// The marker sits beside the title, so it carries no label text: the tooltip
// and the aria-label stay as the accessible explanation.
expect(sessionsSection).not.toContain("t('nav.sessions.worktreeBadge')");
expect(sessionsSection).toMatch(
/inline-item-worktree-badge"[\s\S]{0,400}?<FolderGit2 className="openbitfun-nav-panel__inline-item-worktree-icon" aria-hidden \/>\s*<\/span>/,
);
expect(sessionsSectionStyles).toMatch(
/&__inline-item-worktree-badge \{[\s\S]*?inline-size: var\(--openbitfun-control-icon-size-md\);/,
);
// The glyph keeps a normal icon size inside the badge circle.
expect(sessionsSectionStyles).toMatch(
/\.openbitfun-nav-panel__inline-item-worktree-icon \{[\s\S]*?inline-size: var\(--openbitfun-control-icon-size-xs\);/,
);
});

it('counts only the rows a linked worktree owns when sizing the expand toggle', () => {
// A linked worktree shares its main workspace's session directory, so the
// metadata page total counts the project's sessions too and must not drive
// the expand toggle.
expect(sessionsSection).toMatch(
/const sectionWorkspace = workspaceId\s*\n\s*\? openedWorkspacesList\.find\(workspace => workspace\.id === workspaceId\) \?\? null\s*\n\s*: null;/,
);
expect(sessionsSection).toContain(
'const countOnlyOwnedTopLevelSessions = isLinkedWorktreeWorkspace(sectionWorkspace);',
);
expect(sessionsSection).toMatch(
/!hasActiveSessionFilter && !workspaceScopes\?\.length && !countOnlyOwnedTopLevelSessions\s*\n\s*\? getEffectiveTopLevelSessionCount\(/,
);
expect(sessionsSection).toMatch(
/import \{ isLinkedWorktreeWorkspace \} from '@\/shared\/types\/global-state';/,
);
});

it('explains the worktree execution in the row tooltip and badge title', () => {
const occurrences = sessionsSection.match(
/t\('nav\.sessions\.worktreeTooltip', \{ path: worktreeRootPath \}\)/g,
);
// Tooltip line and badge title/aria-label both surface the directory.
expect(occurrences?.length ?? 0).toBeGreaterThanOrEqual(3);
expect(sessionsSection).toMatch(/worktreeIsolated \|\|\s*\n?\s*isDispatched/);
});

it('offers opening the worktree directory as a workspace only for worktree rows', () => {
expect(sessionsSection).toMatch(
/\{worktreeIsolated && worktreeRootPath \? \(\s*\n\s*<MenuItem/,
);
expect(sessionsSection).toContain('data-testid="nav-session-menu-open-worktree-workspace"');
expect(sessionsSection).toContain('void handleOpenWorktreeWorkspace(e, worktreeRootPath);');
});

it('activates an already open worktree workspace and opens it otherwise', () => {
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<string, string> } };
const sessions = catalog.nav.sessions;

expect(sessions.openWorktreeWorkspace).toBeTruthy();
expect(sessions.openWorktreeWorkspaceFailed).toBeTruthy();
// The badge is icon-only, so it keeps no visible label copy.
expect(sessions.worktreeBadge).toBeUndefined();
// The badge title and the tooltip both interpolate the directory.
expect(sessions.worktreeTooltip).toContain('{{path}}');
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -775,7 +775,8 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<WorkspaceSessionStatus, number> = {
Expand Down
Loading
Loading