diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index aa3f7bad20..0784f3e004 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -48,11 +48,12 @@ These are two distinct concepts that used to be conflated:
| **Tab** (workspace layout) | Per-client | User opens/closes a view |
| **Archive** (lifecycle) | Global | Explicit lifecycle gesture |
-Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
+Agent tab close behavior depends on the client-side workspace organization mode:
-Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
+- **Workspace-first** (default): root agent tabs represent workspace threads. Closing a root agent tab archives the agent; closing a subagent tab is layout-only.
+- **Thread-first**: sidebar thread rows are the persistent home surface. Closing an agent tab is layout-only for both root agents and subagents; closing a thread from its sidebar row is the explicit lifecycle gesture.
-The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
+Archive is always authoritative. Once an agent is archived, pinned or locally-open tab state must not keep it visible.
## Workspace activity
@@ -72,15 +73,13 @@ Archived subagents disappear from the track, by design. To remove a subagent fro
## Why this shape
-The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
+The thread-first organization mode decouples "close tab" from "archive":
-- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
-- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
+- **Closing any agent tab is layout-only** — tabs are view slots, not lifecycle ownership
+- **Close thread on sidebar rows** — gives root agents an explicit lifecycle gesture in their home surface
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
-We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
-
## Limitations
### Subagent accumulation under long-lived parents
@@ -89,7 +88,7 @@ A parent that spawns many subagents will see the track grow. There's no automati
### Cross-client tab dismissal
-Closing a subagent's tab on one client doesn't affect other clients' layouts. This is the expected behavior of decoupled tabs and is consistent with how layouts have always worked. Archive remains the global gesture for cross-client cleanup.
+In thread-first mode, closing an agent tab on one client doesn't affect other clients' layouts. This is the expected behavior of decoupled tabs and is consistent with how layouts have always worked. Archive remains the global gesture for cross-client cleanup.
## Storage
diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx
index 0fd56320b5..a84cde39f0 100644
--- a/packages/app/src/components/agent-list.tsx
+++ b/packages/app/src/components/agent-list.tsx
@@ -317,6 +317,7 @@ export function AgentList({
serverId,
agentId,
pin: Boolean(agent.archivedAt),
+ allowArchived: Boolean(agent.archivedAt),
});
},
[isActionSheetVisible, onAgentSelect],
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 1976c73e98..b2854245aa 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -61,6 +61,11 @@ import {
selectIsAgentListOpen,
usePanelStore,
} from "@/stores/panel-store";
+import { useSessionStore } from "@/stores/session-store";
+import {
+ useWorkspaceOrganizationStore,
+ type WorkspaceOrganizationMode,
+} from "@/stores/workspace-organization-store";
import { resolveActiveHost } from "@/utils/active-host";
import { formatConnectionStatus } from "@/utils/daemons";
import { useWindowControlsPadding } from "@/utils/desktop-window";
@@ -97,6 +102,8 @@ interface SidebarSharedProps {
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
+ selectedAgentId?: string;
+ organizationMode: WorkspaceOrganizationMode;
groupMode: SidebarGroupMode;
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
@@ -128,11 +135,7 @@ interface DesktopSidebarProps extends SidebarSharedProps {
handleViewMore: () => void;
}
-export const LeftSidebar = memo(function LeftSidebar({
- selectedAgentId: _selectedAgentId,
-}: LeftSidebarProps) {
- void _selectedAgentId;
-
+export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isCompactLayout = useIsCompactFormFactor();
@@ -147,6 +150,13 @@ export const LeftSidebar = memo(function LeftSidebar({
[daemons, pathname],
);
const activeServerId = activeDaemon?.serverId ?? null;
+ const focusedAgentId = useSessionStore((state) =>
+ activeServerId ? (state.sessions[activeServerId]?.focusedAgentId ?? null) : null,
+ );
+ const organizationMode = useWorkspaceOrganizationStore((state) => state.mode);
+ const effectiveSelectedAgentId =
+ selectedAgentId ??
+ (activeServerId && focusedAgentId ? `${activeServerId}:${focusedAgentId}` : undefined);
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return "No host";
const trimmed = activeDaemon.label?.trim();
@@ -199,7 +209,11 @@ export const LeftSidebar = memo(function LeftSidebar({
enabled: isCompactLayout || isOpen,
});
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
- useSidebarShortcutModel({ projects, isInitialLoad });
+ useSidebarShortcutModel({
+ projects,
+ isInitialLoad,
+ organizationMode,
+ });
const groupMode = useSidebarViewStore((state) =>
activeServerId ? state.getGroupMode(activeServerId) : "project",
@@ -281,6 +295,8 @@ export const LeftSidebar = memo(function LeftSidebar({
isInitialLoad,
isRevalidating,
isManualRefresh,
+ selectedAgentId: effectiveSelectedAgentId,
+ organizationMode,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
@@ -557,6 +573,8 @@ function MobileSidebar({
handleOpenProject,
handleHome,
handleSettings,
+ selectedAgentId,
+ organizationMode,
insetsTop,
insetsBottom,
isOpen,
@@ -745,7 +763,10 @@ function MobileSidebar({
testID="sidebar-sessions"
/>
-
+
-
+
{isInitialLoad ? (
@@ -925,6 +950,8 @@ function DesktopSidebar({
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
projects={projects}
+ organizationMode={organizationMode}
+ selectedAgentId={selectedAgentId}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
@@ -958,11 +985,19 @@ function DesktopSidebar({
);
}
-function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
+function WorkspacesSectionHeader({
+ serverId,
+ organizationMode,
+}: {
+ serverId: string | null;
+ organizationMode: WorkspaceOrganizationMode;
+}) {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
+ const sectionTitle = organizationMode === "thread-first" ? "Threads" : "Workspaces";
+ const showGroupingSelector = organizationMode === "workspace-first";
const handleNewWorkspacePress = useCallback(() => {
if (!serverId) {
return;
@@ -979,7 +1014,7 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
return (
- Workspaces
+ {sectionTitle}
@@ -1027,16 +1062,18 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
-
-
-
-
-
-
-
-
-
-
+ {showGroupingSelector ? (
+
+
+
+
+
+
+
+
+
+
+ ) : null}
);
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index 97dabea008..ee34993406 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -26,6 +26,7 @@ import {
type ReactElement,
type MutableRefObject,
type Ref,
+ type ComponentType,
} from "react";
import { router, usePathname, type Href } from "expo-router";
import {
@@ -49,6 +50,7 @@ import {
FolderPlus,
GitPullRequest,
Settings,
+ SquarePen,
MoreVertical,
Pencil,
Plus,
@@ -66,6 +68,7 @@ import {
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
+ type SidebarAgentEntry,
useSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
@@ -118,6 +121,7 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio
import type { PrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useSessionStore } from "@/stores/session-store";
+import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import {
@@ -130,10 +134,16 @@ import {
archiveWorkspacesOptimistically,
} from "@/workspace/workspace-archive";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
+import { getProviderIcon } from "@/components/provider-icons";
+import { navigateToAgent } from "@/utils/navigate-to-agent";
+import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
+import { shortenPath } from "@/utils/shorten-path";
+import type { WorkspaceOrganizationMode } from "@/stores/workspace-organization-store";
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
+const EMPTY_WORKSPACE_ROWS: SidebarWorkspaceEntry[] = [];
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
@@ -147,6 +157,7 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedFolderPlus = withUnistyles(FolderPlus);
+const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedSettings = withUnistyles(Settings);
@@ -169,6 +180,27 @@ const syncedLoaderColorMapping = (theme: Theme) => ({
: theme.colors.palette.amber[500],
});
+type ProviderIconUniProps = (theme: Theme) => { color: string };
+
+type ThemedProviderIconComponent = ComponentType<{
+ size: number;
+ uniProps?: ProviderIconUniProps;
+}>;
+
+const themedProviderIconComponents = new Map();
+
+function getThemedProviderIcon(provider: string) {
+ const cached = themedProviderIconComponents.get(provider);
+ if (cached) {
+ return cached;
+ }
+ const ThemedProviderIcon = withUnistyles(
+ getProviderIcon(provider),
+ ) as ThemedProviderIconComponent;
+ themedProviderIconComponents.set(provider, ThemedProviderIcon);
+ return ThemedProviderIcon;
+}
+
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
@@ -197,8 +229,20 @@ function isProjectSelectedByRoute(input: {
selection: ActiveWorkspaceSelection | null;
project: SidebarProjectEntry;
serverId: string | null;
+ selectedAgentId?: string;
+ organizationMode: WorkspaceOrganizationMode;
enabled: boolean;
}): boolean {
+ if (
+ input.organizationMode === "thread-first" &&
+ input.selectedAgentId &&
+ input.project.agents.some(
+ (agent) => `${agent.serverId}:${agent.agentId}` === input.selectedAgentId,
+ )
+ ) {
+ return true;
+ }
+
return (
input.enabled &&
input.selection?.serverId === input.serverId &&
@@ -222,6 +266,8 @@ function selectionForSelectedWorkspace(
interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[];
serverId: string | null;
+ organizationMode: WorkspaceOrganizationMode;
+ selectedAgentId?: string;
collapsedProjectKeys: ReadonlySet;
onToggleProjectCollapsed: (projectKey: string) => void;
shortcutIndexByWorkspaceKey: Map;
@@ -257,6 +303,7 @@ interface ProjectHeaderRowProps {
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
dragHandleProps?: DraggableListDragHandleProps;
+ canCreateThread?: boolean;
}
interface WorkspaceRowInnerProps {
@@ -418,6 +465,69 @@ function StatusDotOverlay({
return ;
}
+function formatAgentStatusLabel(bucket: SidebarAgentEntry["statusBucket"]): string | null {
+ switch (bucket) {
+ case "needs_input":
+ return "Needs input";
+ case "failed":
+ return "Failed";
+ case "running":
+ return "Running";
+ case "attention":
+ return "Attention";
+ case "done":
+ return null;
+ }
+}
+
+function buildAgentLocationLabel(agent: SidebarAgentEntry): string | null {
+ return (
+ agent.branchName ??
+ agent.workspaceName ??
+ (agent.workspaceDirectory ? shortenPath(agent.workspaceDirectory) : null)
+ );
+}
+
+function buildAgentMetaLabel(agent: SidebarAgentEntry): string {
+ const parts = [buildAgentLocationLabel(agent), formatAgentStatusLabel(agent.statusBucket)].filter(
+ Boolean,
+ );
+ return parts.join(" · ");
+}
+
+function AgentStatusIndicator({ agent }: { agent: SidebarAgentEntry }) {
+ const ProviderIcon = getThemedProviderIcon(agent.provider);
+ const dotColorStyle = getStatusDotColorStyle(agent.statusBucket);
+ const statusDotSize = isEmphasizedStatusDotBucket(agent.statusBucket)
+ ? EMPHASIZED_STATUS_DOT_SIZE
+ : DEFAULT_STATUS_DOT_SIZE;
+ const statusDotOffset =
+ statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
+ ? EMPHASIZED_STATUS_DOT_OFFSET
+ : DEFAULT_STATUS_DOT_OFFSET;
+
+ if (agent.statusBucket === "needs_input") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {dotColorStyle ? (
+
+ ) : null}
+
+ );
+}
+
function ProjectLeadingVisual({
displayName,
iconDataUri,
@@ -480,9 +590,11 @@ function ProjectRowTrailingActions({
project,
displayName,
canCreateWorktree,
+ canCreateThread,
isHovered,
isMobileBreakpoint,
isProjectActive,
+ onCreateThreadInWorkspace,
onBeginWorkspaceSetup,
onRemoveProject,
removeProjectStatus,
@@ -490,9 +602,11 @@ function ProjectRowTrailingActions({
project: SidebarProjectEntry;
displayName: string;
canCreateWorktree: boolean;
+ canCreateThread: boolean;
isHovered: boolean;
isMobileBreakpoint: boolean;
isProjectActive: boolean;
+ onCreateThreadInWorkspace: (workspaceId: string) => void;
onBeginWorkspaceSetup: () => void;
onRemoveProject?: () => void;
removeProjectStatus: "idle" | "pending" | "success";
@@ -500,6 +614,15 @@ function ProjectRowTrailingActions({
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
return (
+ {canCreateThread ? (
+
+ ) : null}
{canCreateWorktree ? (
;
const renameLeadingIcon = ;
+const newThreadLeadingIcon = ;
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
@@ -592,6 +716,127 @@ function ProjectKebabMenu({
);
}
+function workspaceThreadTargetLabel(workspace: SidebarWorkspaceEntry): string {
+ const workspaceName = workspace.name.trim();
+ return workspace.branchName ?? (workspaceName.length > 0 ? workspaceName : workspace.workspaceId);
+}
+
+function NewThreadWorkspaceMenuItem({
+ testID,
+ workspace,
+ onCreateThreadInWorkspace,
+}: {
+ testID: string;
+ workspace: SidebarWorkspaceEntry;
+ onCreateThreadInWorkspace: (workspaceId: string) => void;
+}) {
+ const handleSelect = useCallback(() => {
+ onCreateThreadInWorkspace(workspace.workspaceId);
+ }, [onCreateThreadInWorkspace, workspace.workspaceId]);
+
+ return (
+
+ New thread in {workspaceThreadTargetLabel(workspace)}
+
+ );
+}
+
+function NewThreadButton({
+ displayName,
+ workspaces,
+ onCreateThreadInWorkspace,
+ visible,
+ testID,
+}: {
+ displayName: string;
+ workspaces: SidebarWorkspaceEntry[];
+ onCreateThreadInWorkspace: (workspaceId: string) => void;
+ visible: boolean;
+ testID: string;
+}) {
+ const pressableStyle = useCallback(
+ ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
+ styles.projectIconActionButton,
+ !visible && styles.projectIconActionButtonHidden,
+ (Boolean(hovered) || pressed) && styles.projectIconActionButtonHovered,
+ ],
+ [visible],
+ );
+
+ const handlePress = useCallback(
+ (event: GestureResponderEvent) => {
+ event.stopPropagation();
+ const workspace = workspaces[0];
+ if (!workspace) {
+ return;
+ }
+ onCreateThreadInWorkspace(workspace.workspaceId);
+ },
+ [onCreateThreadInWorkspace, workspaces],
+ );
+
+ const renderTriggerIcon = useCallback(
+ ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => (
+
+ ),
+ [],
+ );
+
+ if (workspaces.length > 1) {
+ return (
+
+
+
+ {renderTriggerIcon}
+
+
+ {workspaces.map((workspace) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {renderTriggerIcon}
+
+
+
+ New thread
+
+
+
+ );
+}
+
function WorkspaceRowRightGroup({
workspace,
isHovered,
@@ -756,6 +1001,42 @@ function WorkspaceKebabMenu({
);
}
+function AgentKebabMenu({
+ rowKey,
+ onCloseThread,
+ closeStatus,
+}: {
+ rowKey: string;
+ onCloseThread: () => void;
+ closeStatus: "idle" | "pending";
+}) {
+ return (
+
+
+ {renderKebabTriggerIcon}
+
+
+
+ Close thread
+
+
+
+ );
+}
+
function ProjectIcon({
iconDataUri,
placeholderInitial,
@@ -1153,6 +1434,7 @@ function ProjectHeaderRow({
onPress,
serverId,
canCreateWorktree,
+ canCreateThread = false,
isProjectActive = false,
onWorkspacePress,
onWorktreeCreated: _onWorktreeCreated,
@@ -1180,6 +1462,21 @@ function ProjectHeaderRow({
);
onWorkspacePress?.();
}, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]);
+
+ const handleCreateThreadInWorkspace = useCallback(
+ (workspaceId: string) => {
+ if (!serverId || !workspaceId.trim()) {
+ return;
+ }
+ onWorkspacePress?.();
+ navigateToPreparedWorkspaceTab({
+ serverId,
+ workspaceId,
+ target: { kind: "draft", draftId: "new" },
+ });
+ },
+ [onWorkspacePress, serverId],
+ );
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const _toast = useToast();
@@ -1239,9 +1536,11 @@ function ProjectHeaderRow({
project={project}
displayName={displayName}
canCreateWorktree={canCreateWorktree}
+ canCreateThread={Boolean(canCreateThread && serverId && project.workspaces.length > 0)}
isHovered={isHovered}
isMobileBreakpoint={isMobileBreakpoint}
isProjectActive={isProjectActive}
+ onCreateThreadInWorkspace={handleCreateThreadInWorkspace}
onBeginWorkspaceSetup={handleBeginWorkspaceSetup}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
@@ -1321,9 +1620,10 @@ function WorkspaceRowInner({
onCopyBranchName,
onCopyPath,
onRename,
+ onMarkAsRead,
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
- const _isCompact = useIsCompactFormFactor();
+ const isCompact = useIsCompactFormFactor();
const isTouchPlatform = platformIsNative;
const interaction = useLongPressDragInteraction({
drag,
@@ -1395,7 +1695,7 @@ function WorkspaceRowInner({
@@ -1830,6 +2131,7 @@ function FlattenedProjectRow({
serverId,
onWorkspacePress,
onWorktreeCreated,
+ canCreateThread,
shortcutNumber,
showShortcutBadge,
drag,
@@ -1849,6 +2151,7 @@ function FlattenedProjectRow({
serverId: string | null;
onWorkspacePress?: () => void;
onWorktreeCreated?: (workspaceId: string) => void;
+ canCreateThread: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
drag: () => void;
@@ -1901,6 +2204,7 @@ function FlattenedProjectRow({
onPress={onPress}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
+ canCreateThread={canCreateThread}
isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
@@ -2007,6 +2311,132 @@ function areWorkspaceRowItemPropsEqual(
const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual);
+interface AgentRowItemProps {
+ agent: SidebarAgentEntry;
+ selectedAgentId?: string;
+ onWorkspacePress?: () => void;
+}
+
+function AgentRowItem({ agent, selectedAgentId, onWorkspacePress }: AgentRowItemProps) {
+ const toast = useToast();
+ const isCompact = useIsCompactFormFactor();
+ const { archiveAgent, isArchivingAgent } = useArchiveAgent();
+ const hasClient = useSessionStore((state) =>
+ Boolean(state.sessions[agent.serverId]?.client ?? null),
+ );
+ const [isHovered, setIsHovered] = useState(false);
+ const selected = selectedAgentId === `${agent.serverId}:${agent.agentId}`;
+ const metaLabel = buildAgentMetaLabel(agent);
+ const isArchiving = isArchivingAgent({ serverId: agent.serverId, agentId: agent.agentId });
+ const showKebab = isHovered || platformIsNative || isCompact;
+
+ const handlePress = useCallback(() => {
+ onWorkspacePress?.();
+ navigateToAgent({
+ serverId: agent.serverId,
+ agentId: agent.agentId,
+ pin: true,
+ });
+ }, [agent.agentId, agent.serverId, onWorkspacePress]);
+
+ const handleCloseThread = useCallback(() => {
+ if (isArchiving) {
+ return;
+ }
+ if (!hasClient) {
+ toast.error("Host is not connected");
+ return;
+ }
+
+ void (async () => {
+ const isRunning = agent.statusBucket === "running";
+ const confirmed = await confirmDialog({
+ title: "Close thread?",
+ message: isRunning
+ ? "This thread is still running. Closing it will stop the agent and remove it from active lists."
+ : `Close "${agent.title}"? This archives the thread and removes it from active lists.`,
+ confirmLabel: "Close",
+ cancelLabel: "Cancel",
+ destructive: true,
+ });
+ if (!confirmed) {
+ return;
+ }
+
+ try {
+ await archiveAgent({ serverId: agent.serverId, agentId: agent.agentId });
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to close thread");
+ }
+ })();
+ }, [agent, archiveAgent, hasClient, isArchiving, toast]);
+
+ const handlePointerEnter = useCallback(() => setIsHovered(true), []);
+ const handlePointerLeave = useCallback(() => setIsHovered(false), []);
+
+ const rowStyle = useCallback(
+ ({ pressed }: PressableStateCallbackType) => [
+ styles.workspaceRow,
+ selected && styles.sidebarRowSelected,
+ isHovered && styles.workspaceRowHovered,
+ pressed && styles.workspaceRowPressed,
+ ],
+ [isHovered, selected],
+ );
+
+ const accessibilityState = useMemo(() => ({ selected }), [selected]);
+
+ return (
+
+
+
+
+
+
+
+ {agent.title}
+
+
+ {metaLabel.length > 0 ? (
+
+ {metaLabel}
+
+ ) : null}
+
+ {showKebab ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+function areAgentRowItemPropsEqual(previous: AgentRowItemProps, next: AgentRowItemProps): boolean {
+ return (
+ previous.agent === next.agent &&
+ previous.selectedAgentId === next.selectedAgentId &&
+ previous.onWorkspacePress === next.onWorkspacePress
+ );
+}
+
+const MemoAgentRowItem = memo(AgentRowItem, areAgentRowItemPropsEqual);
+
function WorkspaceRow({
workspace,
shortcutNumber,
@@ -2058,9 +2488,11 @@ function ProjectBlock({
displayName,
iconDataUri,
serverId,
+ organizationMode,
selectionEnabled,
showShortcutBadges,
shortcutIndexByWorkspaceKey,
+ selectedAgentId,
parentGestureRef,
onToggleCollapsed,
onWorkspacePress,
@@ -2078,9 +2510,11 @@ function ProjectBlock({
displayName: string;
iconDataUri: string | null;
serverId: string | null;
+ organizationMode: WorkspaceOrganizationMode;
selectionEnabled: boolean;
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map;
+ selectedAgentId?: string;
parentGestureRef?: MutableRefObject;
onToggleCollapsed: (projectKey: string) => void;
onWorkspacePress?: () => void;
@@ -2098,16 +2532,32 @@ function ProjectBlock({
buildSidebarProjectRowModel({
project,
collapsed,
+ organizationMode,
}),
- [collapsed, project],
+ [collapsed, organizationMode, project],
);
const active = isProjectSelectedByRoute({
selection: activeWorkspaceSelection,
serverId,
project,
+ selectedAgentId,
+ organizationMode,
enabled: selectionEnabled,
});
+ const isThreadFirst = organizationMode === "thread-first";
+
+ const renderAgentRow = useCallback(
+ (agent: SidebarAgentEntry) => (
+
+ ),
+ [onWorkspacePress, selectedAgentId],
+ );
const renderWorkspaceRow = useCallback(
(
@@ -2224,6 +2674,35 @@ function ProjectBlock({
onToggleCollapsed(project.projectKey);
}, [onToggleCollapsed, project.projectKey]);
+ let childRows: ReactElement | null = null;
+ if (!collapsed) {
+ if (isThreadFirst && project.agents.length > 0) {
+ childRows = (
+
+ {project.agents.map(renderAgentRow)}
+
+ );
+ } else {
+ childRows = (
+
+ );
+ }
+ }
+
return (
{rowModel.kind === "workspace_link" ? (
@@ -2234,6 +2713,7 @@ function ProjectBlock({
rowModel={rowModel}
onPress={handleFlattenedRowPress}
serverId={serverId}
+ canCreateThread={isThreadFirst}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null}
@@ -2259,6 +2739,7 @@ function ProjectBlock({
onPress={handleToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
+ canCreateThread={isThreadFirst}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
@@ -2271,21 +2752,7 @@ function ProjectBlock({
dragHandleProps={dragHandleProps}
/>
- {!collapsed ? (
-
- ) : null}
+ {childRows}
>
)}
@@ -2295,26 +2762,46 @@ function ProjectBlock({
type ProjectBlockProps = Parameters[0];
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
+ return (
+ areProjectBlockValuePropsEqual(previous, next) &&
+ areProjectBlockCallbackPropsEqual(previous, next) &&
+ areProjectBlockSelectionsEqual(previous, next)
+ );
+}
+
+function areProjectBlockValuePropsEqual(
+ previous: ProjectBlockProps,
+ next: ProjectBlockProps,
+): boolean {
return (
previous.project === next.project &&
previous.collapsed === next.collapsed &&
previous.displayName === next.displayName &&
previous.iconDataUri === next.iconDataUri &&
previous.serverId === next.serverId &&
+ previous.organizationMode === next.organizationMode &&
previous.selectionEnabled === next.selectionEnabled &&
previous.showShortcutBadges === next.showShortcutBadges &&
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
+ previous.selectedAgentId === next.selectedAgentId &&
+ previous.isDragging === next.isDragging &&
+ previous.dragHandleProps === next.dragHandleProps &&
+ previous.useNestable === next.useNestable &&
+ previous.creatingWorkspaceIds === next.creatingWorkspaceIds
+ );
+}
+
+function areProjectBlockCallbackPropsEqual(
+ previous: ProjectBlockProps,
+ next: ProjectBlockProps,
+): boolean {
+ return (
previous.parentGestureRef === next.parentGestureRef &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
previous.onWorktreeCreated === next.onWorktreeCreated &&
- previous.drag === next.drag &&
- previous.isDragging === next.isDragging &&
- previous.dragHandleProps === next.dragHandleProps &&
- previous.useNestable === next.useNestable &&
- previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
- areProjectBlockSelectionsEqual(previous, next)
+ previous.drag === next.drag
);
}
@@ -2326,12 +2813,16 @@ function areProjectBlockSelectionsEqual(
selection: previous.activeWorkspaceSelection,
project: previous.project,
serverId: previous.serverId,
+ selectedAgentId: previous.selectedAgentId,
+ organizationMode: previous.organizationMode,
enabled: previous.selectionEnabled,
});
const nextActive = isProjectSelectedByRoute({
selection: next.activeWorkspaceSelection,
project: next.project,
serverId: next.serverId,
+ selectedAgentId: next.selectedAgentId,
+ organizationMode: next.organizationMode,
enabled: next.selectionEnabled,
});
if (previousActive !== nextActive) {
@@ -2351,6 +2842,8 @@ const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
export function SidebarWorkspaceList({
projects,
serverId,
+ organizationMode,
+ selectedAgentId,
collapsedProjectKeys,
onToggleProjectCollapsed,
shortcutIndexByWorkspaceKey,
@@ -2364,7 +2857,7 @@ export function SidebarWorkspaceList({
}: SidebarWorkspaceListProps) {
const pathname = usePathname();
- if (groupMode === "status") {
+ if (groupMode === "status" && organizationMode !== "thread-first") {
return (
);
}
@@ -2429,6 +2924,8 @@ function ProjectModeList({
listFooterComponent,
parentGestureRef,
pathname,
+ organizationMode,
+ selectedAgentId,
}: Omit & {
pathname: string;
}) {
@@ -2603,9 +3100,11 @@ function ProjectModeList({
displayName={item.projectName}
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
serverId={serverId}
+ organizationMode={organizationMode}
selectionEnabled={selectionEnabled}
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
+ selectedAgentId={selectedAgentId}
parentGestureRef={parentGestureRef}
onToggleCollapsed={onToggleProjectCollapsed}
onWorkspacePress={onWorkspacePress}
@@ -2627,9 +3126,11 @@ function ProjectModeList({
handleWorkspaceReorder,
onWorkspacePress,
onToggleProjectCollapsed,
+ organizationMode,
parentGestureRef,
projectIconByProjectKey,
selectionEnabled,
+ selectedAgentId,
serverId,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
@@ -2829,7 +3330,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
projectIconActionButtonHovered: {
- backgroundColor: theme.colors.surfaceSidebarHover,
+ backgroundColor: theme.colors.surface2,
},
projectIconActionButtonHidden: {
opacity: 0,
@@ -2977,6 +3478,26 @@ const styles = StyleSheet.create((theme) => ({
workspaceBranchTextHovered: {
opacity: 1,
},
+ agentRowContent: {
+ flex: 1,
+ minWidth: 0,
+ gap: 1,
+ },
+ agentTitleText: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.sm,
+ fontWeight: "400",
+ lineHeight: 20,
+ flex: 1,
+ minWidth: 0,
+ },
+ agentMetaText: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.xs,
+ lineHeight: 16,
+ paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
+ minWidth: 0,
+ },
workspacePrBadgeRow: {
flexDirection: "row",
alignItems: "center",
diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx
index 2f7075d18a..3ad27de44d 100644
--- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx
+++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx
@@ -11,6 +11,7 @@ import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-stor
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
+import { useWorkspaceOrganizationStore } from "@/stores/workspace-organization-store";
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
vi.hoisted(() => {
@@ -64,6 +65,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
useSidebarViewStore.setState({
groupModeByServerId: {},
});
+ useWorkspaceOrganizationStore.getState().setMode("workspace-first");
act(() => {
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
@@ -169,6 +171,19 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
]);
});
+ it("does not publish workspace shortcuts in thread-first mode even when status grouping is selected", async () => {
+ act(() => {
+ useSidebarViewStore.getState().setGroupMode("srv", "status");
+ useWorkspaceOrganizationStore.getState().setMode("thread-first");
+ });
+
+ await act(async () => {
+ root?.render();
+ });
+
+ expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
+ });
+
it("clears targets when disabled", async () => {
await act(async () => {
root?.render();
diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx
index 935dbe57c7..65fe420c62 100644
--- a/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx
+++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx
@@ -7,6 +7,7 @@ import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
+import { useWorkspaceOrganizationStore } from "@/stores/workspace-organization-store";
import {
buildSidebarShortcutModel,
buildStatusSidebarShortcutModel,
@@ -37,8 +38,17 @@ export function WorkspaceShortcutTargetsSubscriber({
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setSidebarShortcutWorkspaceTargets,
);
+ const organizationMode = useWorkspaceOrganizationStore((state) => state.mode);
const shortcutModel = useMemo(() => {
+ if (organizationMode === "thread-first") {
+ return buildSidebarShortcutModel({
+ projects,
+ collapsedProjectKeys,
+ organizationMode,
+ });
+ }
+
if (groupMode === "status") {
return buildStatusSidebarShortcutModel({
workspaces: statusWorkspaces,
@@ -50,11 +60,13 @@ export function WorkspaceShortcutTargetsSubscriber({
return buildSidebarShortcutModel({
projects,
collapsedProjectKeys,
+ organizationMode,
});
}, [
collapsedProjectKeys,
collapsedStatusGroupKeys,
groupMode,
+ organizationMode,
projectNamesByKey,
projects,
statusWorkspaces,
diff --git a/packages/app/src/hooks/sidebar-status-view-model.test.ts b/packages/app/src/hooks/sidebar-status-view-model.test.ts
index d3400e2602..c26e4967ed 100644
--- a/packages/app/src/hooks/sidebar-status-view-model.test.ts
+++ b/packages/app/src/hooks/sidebar-status-view-model.test.ts
@@ -20,6 +20,7 @@ function ws(
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? "main",
+ branchName: input.branchName ?? null,
statusBucket: input.statusBucket ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: null,
diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts
index 5532c9b6b4..8050920395 100644
--- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts
+++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts
@@ -1,11 +1,14 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
+import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarProjectsFromStructure,
+ buildSidebarProjectsWithAgents,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
+ type SidebarAgentProjectionSource,
type SidebarProjectEntry,
} from "./sidebar-workspaces-view-model";
@@ -23,6 +26,7 @@ function project(input: {
projectKind?: WorkspaceStructureProject["projectKind"];
iconWorkingDir?: string;
workspaceKeys: string[];
+ workspaceDetailsById?: WorkspaceStructureProject["workspaceDetailsById"];
}): WorkspaceStructureProject {
return {
projectKey: input.projectKey,
@@ -30,6 +34,7 @@ function project(input: {
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
workspaceKeys: input.workspaceKeys,
+ workspaceDetailsById: input.workspaceDetailsById,
};
}
@@ -49,6 +54,71 @@ function sidebarProject(input: {
return result;
}
+function workspaceDescriptor(overrides: Partial = {}): WorkspaceDescriptor {
+ const { statusEnteredAt = null, ...rest } = overrides;
+ return {
+ id: "ws-main",
+ projectId: "project-1",
+ projectDisplayName: "Project 1",
+ projectCustomName: null,
+ projectRootPath: "/repo",
+ workspaceDirectory: "/repo",
+ projectKind: "git",
+ workspaceKind: "checkout",
+ name: "main",
+ status: "done",
+ statusEnteredAt,
+ archivingAt: null,
+ diffStat: null,
+ scripts: [],
+ project: {
+ projectKey: "project-1",
+ projectName: "Project 1",
+ checkout: {
+ cwd: "/repo",
+ isGit: true,
+ currentBranch: "main",
+ remoteUrl: null,
+ worktreeRoot: "/repo",
+ isPaseoOwnedWorktree: false,
+ mainRepoRoot: null,
+ },
+ },
+ ...rest,
+ };
+}
+
+function agent(
+ overrides: Partial = {},
+): SidebarAgentProjectionSource {
+ return {
+ id: "agent-1",
+ serverId: "srv",
+ title: "Trial sidebar",
+ status: "idle",
+ cwd: "/repo",
+ provider: "codex",
+ lastActivityAt: new Date("2026-06-02T12:00:00.000Z"),
+ pendingPermissionCount: 0,
+ requiresAttention: false,
+ archivedAt: null,
+ projectPlacement: {
+ projectKey: "project-1",
+ projectName: "Project 1",
+ checkout: {
+ cwd: "/repo",
+ isGit: true,
+ currentBranch: "main",
+ remoteUrl: null,
+ worktreeRoot: "/repo",
+ isPaseoOwnedWorktree: false,
+ mainRepoRoot: null,
+ },
+ },
+ ...overrides,
+ };
+}
+
describe("applyStoredOrdering", () => {
it("keeps unknown items on the baseline while applying stored order", () => {
const result = applyStoredOrdering({
@@ -129,6 +199,35 @@ describe("buildSidebarProjectsFromStructure", () => {
});
});
+ it("preserves branch metadata for structural workspace rows", () => {
+ const projects = buildSidebarProjectsFromStructure({
+ serverId: "srv",
+ projects: [
+ project({
+ projectKey: "project-1",
+ projectName: "Project 1",
+ iconWorkingDir: "/repo",
+ workspaceKeys: ["/Users/ethan/paseo"],
+ workspaceDetailsById: {
+ "/Users/ethan/paseo": {
+ workspaceId: "/Users/ethan/paseo",
+ workspaceName: "/Users/ethan/paseo",
+ workspaceDirectory: "/Users/ethan/paseo",
+ workspaceKind: "local_checkout",
+ currentBranch: "feature/sidebar",
+ },
+ },
+ }),
+ ],
+ });
+
+ expect(projects[0]?.workspaces[0]).toMatchObject({
+ workspaceId: "/Users/ethan/paseo",
+ name: "/Users/ethan/paseo",
+ branchName: "feature/sidebar",
+ });
+ });
+
it("preserves the structure hook project order", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
@@ -154,6 +253,105 @@ describe("buildSidebarProjectsFromStructure", () => {
});
});
+describe("buildSidebarProjectsWithAgents", () => {
+ it("groups active agents under an existing project by workspace cwd", () => {
+ const baseProjects = buildSidebarProjectsFromStructure({
+ serverId: "srv",
+ projects: [
+ project({
+ projectKey: "project-1",
+ projectName: "Project 1",
+ iconWorkingDir: "/repo",
+ workspaceKeys: ["ws-main"],
+ }),
+ ],
+ });
+
+ const projects = buildSidebarProjectsWithAgents({
+ projects: baseProjects,
+ agents: [agent({ status: "running", pendingPermissionCount: 2 })],
+ workspaces: [workspaceDescriptor()],
+ });
+
+ expect(projects).toHaveLength(1);
+ expect(projects[0]?.agents).toEqual([
+ expect.objectContaining({
+ rowKey: "srv:agent:agent-1",
+ agentId: "agent-1",
+ projectKey: "project-1",
+ workspaceId: "ws-main",
+ title: "Trial sidebar",
+ statusBucket: "needs_input",
+ branchName: "main",
+ }),
+ ]);
+ });
+
+ it("does not count initializing agents as running", () => {
+ const baseProjects = buildSidebarProjectsFromStructure({
+ serverId: "srv",
+ projects: [
+ project({
+ projectKey: "project-1",
+ projectName: "Project 1",
+ iconWorkingDir: "/repo",
+ workspaceKeys: ["ws-main"],
+ }),
+ ],
+ });
+
+ const projects = buildSidebarProjectsWithAgents({
+ projects: baseProjects,
+ agents: [agent({ status: "initializing" })],
+ workspaces: [workspaceDescriptor()],
+ });
+
+ expect(projects[0]?.agents[0]?.statusBucket).toBe("done");
+ });
+
+ it("creates a synthetic project for active agents without a workspace descriptor", () => {
+ const projects = buildSidebarProjectsWithAgents({
+ projects: [],
+ agents: [
+ agent({
+ id: "agent-orphan",
+ cwd: "/repo/worktree-a",
+ projectPlacement: {
+ projectKey: "project-1",
+ projectName: "Project 1",
+ checkout: {
+ cwd: "/repo/worktree-a",
+ isGit: true,
+ currentBranch: "trial",
+ remoteUrl: null,
+ worktreeRoot: "/repo/worktree-a",
+ isPaseoOwnedWorktree: false,
+ mainRepoRoot: null,
+ },
+ },
+ }),
+ ],
+ workspaces: [],
+ });
+
+ expect(projects).toEqual([
+ expect.objectContaining({
+ projectKey: "project-1",
+ projectName: "Project 1",
+ projectKind: "git",
+ workspaces: [],
+ agents: [
+ expect.objectContaining({
+ agentId: "agent-orphan",
+ workspaceId: null,
+ branchName: "trial",
+ }),
+ ],
+ }),
+ ]);
+ });
+});
+
describe("computeSidebarOrderUpdates", () => {
it("returns no updates when there are no visible projects", () => {
const updates = computeSidebarOrderUpdates({
diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts
index 3bac1196db..12d8f92bcb 100644
--- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts
+++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts
@@ -5,11 +5,58 @@ import {
} from "@/projects/host-project-model";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
+import { normalizeWorkspacePath } from "@/utils/workspace-identity";
+import type { AgentProvider } from "@getpaseo/protocol/agent-types";
+import type { ProjectPlacementPayload } from "@getpaseo/protocol/messages";
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
export type SidebarStateBucket = WorkspaceDescriptor["status"];
+export interface SidebarAgentProjectionSource {
+ id: string;
+ serverId: string;
+ title: string | null;
+ status: "initializing" | "idle" | "running" | "error" | "closed";
+ cwd: string;
+ provider: AgentProvider;
+ lastActivityAt: Date;
+ pendingPermissionCount: number;
+ requiresAttention?: boolean;
+ archivedAt?: Date | null;
+ projectPlacement?: ProjectPlacementPayload | null;
+}
+
+export interface SidebarAgentWorkspaceSource {
+ id: string;
+ projectId: string;
+ projectRootPath: string;
+ workspaceDirectory: string;
+ projectKind: WorkspaceDescriptor["projectKind"];
+ workspaceKind: WorkspaceDescriptor["workspaceKind"];
+ name: string;
+ gitRuntime?: { currentBranch?: string | null } | null;
+ project?: ProjectPlacementPayload;
+}
+
+export interface SidebarAgentEntry {
+ rowKey: string;
+ serverId: string;
+ agentId: string;
+ projectKey: string;
+ workspaceId: string | null;
+ workspaceDirectory: string | null;
+ workspaceName: string | null;
+ workspaceKind: WorkspaceDescriptor["workspaceKind"] | null;
+ title: string;
+ statusBucket: SidebarStateBucket;
+ provider: AgentProvider;
+ branchName: string | null;
+ lastActivityAt: Date;
+ pendingPermissionCount: number;
+ requiresAttention: boolean;
+}
+
export interface SidebarWorkspaceEntry {
workspaceKey: string;
serverId: string;
@@ -20,6 +67,7 @@ export interface SidebarWorkspaceEntry {
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
+ branchName: string | null;
statusBucket: SidebarStateBucket;
statusEnteredAt: Date | null;
archivingAt: string | null;
@@ -38,6 +86,15 @@ export interface SidebarProjectEntry {
iconWorkingDir: string;
canCreateWorktree: boolean;
workspaces: SidebarWorkspaceEntry[];
+ agents: SidebarAgentEntry[];
+}
+
+export function normalizeSidebarBranchName(branchName: string | null | undefined): string | null {
+ const value = branchName?.trim();
+ if (!value || value === "HEAD") {
+ return null;
+ }
+ return value;
}
function createStructuralWorkspaceEntry(input: {
@@ -45,16 +102,19 @@ function createStructuralWorkspaceEntry(input: {
project: HostProjectListItem;
workspaceId: string;
}): SidebarWorkspaceEntry {
+ const details = input.project.workspaceDetailsById?.[input.workspaceId];
+
return {
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
projectKey: input.project.projectKey,
projectRootPath: input.project.iconWorkingDir,
- workspaceDirectory: undefined,
+ workspaceDirectory: details?.workspaceDirectory,
projectKind: input.project.projectKind,
- workspaceKind: "checkout",
- name: input.workspaceId,
+ workspaceKind: details?.workspaceKind ?? "checkout",
+ name: details?.workspaceName?.trim() || input.workspaceId,
+ branchName: normalizeSidebarBranchName(details?.currentBranch),
statusBucket: "done",
statusEnteredAt: null,
archivingAt: null,
@@ -79,6 +139,7 @@ export function buildSidebarProjectsFromStructure(input: {
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
workspaceKeys: project.workspaceKeys,
+ workspaceDetailsById: project.workspaceDetailsById,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
})),
});
@@ -104,9 +165,148 @@ export function buildSidebarProjectsFromHostProjects(input: {
workspaceId,
}),
),
+ agents: [],
}));
}
+function resolveAgentStatusBucket(agent: SidebarAgentProjectionSource): SidebarStateBucket {
+ if (agent.pendingPermissionCount > 0) {
+ return "needs_input";
+ }
+ if (agent.status === "error") {
+ return "failed";
+ }
+ if (agent.status === "running") {
+ return "running";
+ }
+ if (agent.requiresAttention) {
+ return "attention";
+ }
+ return "done";
+}
+
+function compareSidebarAgents(left: SidebarAgentEntry, right: SidebarAgentEntry): number {
+ const leftRunning = left.statusBucket === "running" ? 1 : 0;
+ const rightRunning = right.statusBucket === "running" ? 1 : 0;
+ if (leftRunning !== rightRunning) {
+ return rightRunning - leftRunning;
+ }
+
+ const leftAttention =
+ left.statusBucket === "needs_input" || left.statusBucket === "attention" ? 1 : 0;
+ const rightAttention =
+ right.statusBucket === "needs_input" || right.statusBucket === "attention" ? 1 : 0;
+ if (leftAttention !== rightAttention) {
+ return rightAttention - leftAttention;
+ }
+
+ return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
+}
+
+function findWorkspaceForAgent(input: {
+ agent: SidebarAgentProjectionSource;
+ workspaces: SidebarAgentWorkspaceSource[];
+}): SidebarAgentWorkspaceSource | null {
+ const normalizedCwd = normalizeWorkspacePath(input.agent.cwd);
+ if (!normalizedCwd) {
+ return null;
+ }
+
+ return (
+ input.workspaces.find(
+ (workspace) => normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedCwd,
+ ) ?? null
+ );
+}
+
+function projectKindFromPlacement(
+ placement: ProjectPlacementPayload | null | undefined,
+): WorkspaceDescriptor["projectKind"] {
+ return placement?.checkout.isGit ? "git" : "directory";
+}
+
+function createSidebarAgentEntry(input: {
+ agent: SidebarAgentProjectionSource;
+ workspace: SidebarAgentWorkspaceSource | null;
+ projectKey: string;
+}): SidebarAgentEntry {
+ const { agent, workspace, projectKey } = input;
+ const branchName =
+ normalizeSidebarBranchName(workspace?.gitRuntime?.currentBranch) ??
+ normalizeSidebarBranchName(agent.projectPlacement?.checkout.currentBranch);
+ const workspaceName = workspace?.name?.trim() || null;
+
+ return {
+ rowKey: `${agent.serverId}:agent:${agent.id}`,
+ serverId: agent.serverId,
+ agentId: agent.id,
+ projectKey,
+ workspaceId: workspace?.id ?? null,
+ workspaceDirectory: workspace?.workspaceDirectory ?? normalizeWorkspacePath(agent.cwd),
+ workspaceName,
+ workspaceKind: workspace?.workspaceKind ?? null,
+ title: agent.title?.trim() || "New agent",
+ statusBucket: resolveAgentStatusBucket(agent),
+ provider: agent.provider,
+ branchName,
+ lastActivityAt: agent.lastActivityAt,
+ pendingPermissionCount: agent.pendingPermissionCount,
+ requiresAttention: agent.requiresAttention ?? false,
+ };
+}
+
+export function buildSidebarProjectsWithAgents(input: {
+ projects: SidebarProjectEntry[];
+ agents: SidebarAgentProjectionSource[];
+ workspaces: SidebarAgentWorkspaceSource[];
+}): SidebarProjectEntry[] {
+ const activeAgents = input.agents.filter((agent) => !agent.archivedAt);
+ if (activeAgents.length === 0) {
+ return input.projects;
+ }
+
+ const projectsByKey = new Map();
+ const orderedProjects: SidebarProjectEntry[] = input.projects.map((project) => {
+ const nextProject: SidebarProjectEntry = { ...project, agents: [] };
+ projectsByKey.set(nextProject.projectKey, nextProject);
+ return nextProject;
+ });
+
+ for (const agent of activeAgents) {
+ const workspace = findWorkspaceForAgent({ agent, workspaces: input.workspaces });
+ const projectKey =
+ workspace?.project?.projectKey ??
+ workspace?.projectId ??
+ agent.projectPlacement?.projectKey ??
+ agent.cwd;
+ let project = projectsByKey.get(projectKey);
+
+ if (!project) {
+ project = {
+ projectKey,
+ projectName: agent.projectPlacement?.projectName ?? projectKey,
+ projectKind: workspace?.projectKind ?? projectKindFromPlacement(agent.projectPlacement),
+ iconWorkingDir: workspace?.projectRootPath ?? agent.cwd,
+ canCreateWorktree: canCreateWorktreeForProjectKind(
+ workspace?.projectKind ?? projectKindFromPlacement(agent.projectPlacement),
+ ),
+ workspaces: [],
+ agents: [],
+ };
+ projectsByKey.set(projectKey, project);
+ orderedProjects.push(project);
+ }
+
+ project.agents.push(createSidebarAgentEntry({ agent, workspace, projectKey }));
+ }
+
+ for (const project of orderedProjects) {
+ project.agents.sort(compareSidebarAgents);
+ }
+
+ return orderedProjects;
+}
+
export function applyStoredOrdering(input: {
items: T[];
storedOrder: string[];
diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.ts b/packages/app/src/hooks/use-sidebar-shortcut-model.ts
index 80cfe7a4b7..9bc404a828 100644
--- a/packages/app/src/hooks/use-sidebar-shortcut-model.ts
+++ b/packages/app/src/hooks/use-sidebar-shortcut-model.ts
@@ -1,5 +1,6 @@
import { useEffect, useMemo } from "react";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
+import type { WorkspaceOrganizationMode } from "@/stores/workspace-organization-store";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
@@ -7,8 +8,9 @@ import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sec
export function useSidebarShortcutModel(input: {
projects: SidebarProjectEntry[];
isInitialLoad: boolean;
+ organizationMode: WorkspaceOrganizationMode;
}) {
- const { projects, isInitialLoad } = input;
+ const { projects, isInitialLoad, organizationMode } = input;
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
@@ -24,8 +26,9 @@ export function useSidebarShortcutModel(input: {
buildSidebarShortcutModel({
projects,
collapsedProjectKeys,
+ organizationMode,
}),
- [collapsedProjectKeys, projects],
+ [collapsedProjectKeys, organizationMode, projects],
);
useEffect(() => {
@@ -35,7 +38,7 @@ export function useSidebarShortcutModel(input: {
const collapsibleProjectKeys = new Set(
projects
- .filter((project) => !isSidebarProjectFlattened(project))
+ .filter((project) => !isSidebarProjectFlattened(project, organizationMode))
.map((project) => project.projectKey),
);
for (const key of collapsedProjectKeys) {
@@ -43,7 +46,7 @@ export function useSidebarShortcutModel(input: {
setProjectCollapsed(key, false);
}
}
- }, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]);
+ }, [collapsedProjectKeys, isInitialLoad, organizationMode, projects, setProjectCollapsed]);
return {
collapsedProjectKeys,
diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts
index 25be332ef9..4b4789c5eb 100644
--- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts
+++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
-import { useWorkspaceFields } from "@/stores/session-store-hooks";
+import { useSidebarAgentWorkspaces, useWorkspaceFields } from "@/stores/session-store-hooks";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
@@ -11,10 +11,14 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
+ buildSidebarProjectsWithAgents,
buildSidebarProjectsFromHostProjects,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
+ normalizeSidebarBranchName,
type SidebarProjectEntry,
+ type SidebarAgentProjectionSource,
+ type SidebarAgentWorkspaceSource,
type SidebarWorkspaceEntry,
} from "./sidebar-workspaces-view-model";
@@ -23,10 +27,14 @@ export {
applyStoredOrdering,
buildSidebarProjectsFromHostProjects,
buildSidebarProjectsFromStructure,
+ buildSidebarProjectsWithAgents,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
type SidebarLoadingState,
type SidebarOrderUpdates,
+ type SidebarAgentEntry,
+ type SidebarAgentProjectionSource,
+ type SidebarAgentWorkspaceSource,
type SidebarProjectEntry,
type SidebarStateBucket,
type SidebarWorkspaceEntry,
@@ -49,6 +57,7 @@ export function createSidebarWorkspaceEntry(input: {
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
+ branchName: normalizeSidebarBranchName(input.workspace.gitRuntime?.currentBranch),
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
archivingAt: input.workspace.archivingAt,
@@ -193,6 +202,10 @@ export function useSidebarWorkspacesList(options?: {
const hasHydratedWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
+ const sessionAgents = useSessionStore((state) =>
+ isActive && serverId ? state.sessions[serverId]?.agents : undefined,
+ );
+ const sidebarWorkspaces = useSidebarAgentWorkspaces(isActive ? serverId : null);
const hostProjects = useHostProjects(isActive ? serverId : null);
const connectionStatus = useSyncExternalStore(
@@ -214,7 +227,7 @@ export function useSidebarWorkspacesList(options?: {
},
);
- const projects = useMemo(() => {
+ const baseProjects = useMemo(() => {
if (!serverId || hostProjects.length === 0) {
return EMPTY_PROJECTS;
}
@@ -223,6 +236,34 @@ export function useSidebarWorkspacesList(options?: {
});
}, [hostProjects, serverId]);
+ const sidebarAgents = useMemo(
+ () =>
+ Array.from(sessionAgents?.values() ?? []).map((agent) => ({
+ id: agent.id,
+ serverId: agent.serverId,
+ title: agent.title,
+ status: agent.status,
+ cwd: agent.cwd,
+ provider: agent.provider,
+ lastActivityAt: agent.lastActivityAt,
+ pendingPermissionCount: agent.pendingPermissions.length,
+ requiresAttention: agent.requiresAttention,
+ archivedAt: agent.archivedAt,
+ projectPlacement: agent.projectPlacement,
+ })),
+ [sessionAgents],
+ );
+
+ const projects = useMemo(
+ () =>
+ buildSidebarProjectsWithAgents({
+ projects: baseProjects,
+ agents: sidebarAgents,
+ workspaces: sidebarWorkspaces,
+ }),
+ [baseProjects, sidebarAgents, sidebarWorkspaces],
+ );
+
useEffect(() => {
if (!serverId) {
return;
@@ -257,6 +298,7 @@ export function useSidebarWorkspacesList(options?: {
if (!client) {
return;
}
+ void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
void (async () => {
const next = new Map();
try {
diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx
index 57f20a63e9..5fda06a3dc 100644
--- a/packages/app/src/panels/agent-panel.tsx
+++ b/packages/app/src/panels/agent-panel.tsx
@@ -59,7 +59,6 @@ import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store";
import { type Agent, useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
-import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import type { Theme } from "@/styles/theme";
import { useArchiveSubagent, useSubagentsForParent } from "@/subagents";
import { SubagentsTrack } from "@/subagents/track";
@@ -1324,7 +1323,7 @@ function ActiveAgentComposer({
{ initialIsBelow: isCompactFormFactor },
);
const paneContext = usePaneContext();
- const { workspaceId, tabId, retargetCurrentTab } = paneContext;
+ const { workspaceId, tabScopeKey, tabId, retargetCurrentTab } = paneContext;
const { archiveAgent } = useArchiveAgent();
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
@@ -1377,10 +1376,9 @@ function ActiveAgentComposer({
throw new Error("Agent not found");
}
- const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
- if (workspaceKey) {
- unpinWorkspaceAgent(workspaceKey, agentId);
- hideWorkspaceAgent(workspaceKey, agentId);
+ if (tabScopeKey) {
+ unpinWorkspaceAgent(tabScopeKey, agentId);
+ hideWorkspaceAgent(tabScopeKey, agentId);
}
if (command.kind === "replace-agent-with-draft") {
@@ -1389,8 +1387,8 @@ function ActiveAgentComposer({
draftId: generateDraftId(),
setup: buildDraftAgentSetup(agent),
});
- } else if (workspaceKey) {
- closeWorkspaceTab(workspaceKey, tabId);
+ } else if (tabScopeKey) {
+ closeWorkspaceTab(tabScopeKey, tabId);
}
await archiveAgent({ serverId, agentId });
@@ -1402,9 +1400,9 @@ function ActiveAgentComposer({
hideWorkspaceAgent,
retargetCurrentTab,
serverId,
+ tabScopeKey,
tabId,
unpinWorkspaceAgent,
- workspaceId,
],
);
diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx
index 6ec608db38..95f118a610 100644
--- a/packages/app/src/panels/pane-context.tsx
+++ b/packages/app/src/panels/pane-context.tsx
@@ -6,6 +6,7 @@ import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface PaneContextValue {
serverId: string;
workspaceId: string;
+ tabScopeKey: string | null;
tabId: string;
target: WorkspaceTabTarget;
openTab: (target: WorkspaceTabTarget) => void;
diff --git a/packages/app/src/projects/host-project-model.ts b/packages/app/src/projects/host-project-model.ts
index 6b79d2b998..4cfb4b2c8b 100644
--- a/packages/app/src/projects/host-project-model.ts
+++ b/packages/app/src/projects/host-project-model.ts
@@ -1,5 +1,8 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
-import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
+import type {
+ WorkspaceStructureProject,
+ WorkspaceStructureWorkspaceDetails,
+} from "@/projects/workspace-structure";
export interface HostProjectListItem {
serverId: string;
@@ -8,6 +11,7 @@ export interface HostProjectListItem {
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
workspaceKeys: string[];
+ workspaceDetailsById?: Record;
canCreateWorktree: boolean;
}
@@ -40,6 +44,7 @@ export function buildHostProjectList(input: {
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
workspaceKeys: project.workspaceKeys,
+ workspaceDetailsById: project.workspaceDetailsById,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
}));
}
diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts
index eb02322937..117e4b2afa 100644
--- a/packages/app/src/projects/workspace-structure.ts
+++ b/packages/app/src/projects/workspace-structure.ts
@@ -7,6 +7,15 @@ export interface WorkspaceStructureProject {
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
workspaceKeys: string[];
+ workspaceDetailsById?: Record;
+}
+
+export interface WorkspaceStructureWorkspaceDetails {
+ workspaceId: string;
+ workspaceName: string;
+ workspaceDirectory: string;
+ workspaceKind: WorkspaceDescriptor["workspaceKind"];
+ currentBranch: string | null;
}
export interface WorkspaceStructure {
@@ -54,7 +63,8 @@ export function buildWorkspaceStructureProjects(input: {
const byProject = new Map<
string,
WorkspaceStructureProject & {
- workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
+ workspaces: Array;
+ workspaceDetailsById: Record;
}
>();
@@ -68,16 +78,25 @@ export function buildWorkspaceStructureProjects(input: {
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
workspaceKeys: [],
+ workspaceDetailsById: {},
workspaces: [],
} satisfies WorkspaceStructureProject & {
- workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
+ workspaces: Array;
+ workspaceDetailsById: Record;
});
- project.workspaces.push({
+ const workspaceDetails = {
workspaceId: workspace.id,
workspaceName: workspace.name,
+ workspaceDirectory: workspace.workspaceDirectory,
+ workspaceKind: workspace.workspaceKind,
+ currentBranch: workspace.gitRuntime?.currentBranch ?? null,
+ };
+ project.workspaces.push({
+ ...workspaceDetails,
workspaceKey: `${input.serverId}:${workspace.id}`,
});
+ project.workspaceDetailsById[workspace.id] = workspaceDetails;
byProject.set(workspace.projectId, project);
}
diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx
index 6d0ab7a2ec..6ac14d1550 100644
--- a/packages/app/src/screens/settings-screen.tsx
+++ b/packages/app/src/screens/settings-screen.tsx
@@ -55,6 +55,11 @@ import {
type ServiceUrlBehavior,
type Settings as EffectiveSettings,
} from "@/hooks/use-settings";
+import {
+ WORKSPACE_ORGANIZATION_MODE_OPTIONS,
+ useWorkspaceOrganizationStore,
+ type WorkspaceOrganizationMode,
+} from "@/stores/workspace-organization-store";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
@@ -221,8 +226,10 @@ const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "ext
interface GeneralSectionProps {
settings: AppSettings;
+ workspaceOrganizationMode: WorkspaceOrganizationMode;
isDesktopApp: boolean;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
+ handleWorkspaceOrganizationModeChange: (mode: WorkspaceOrganizationMode) => void;
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
handleTerminalScrollbackLinesChange: (lines: number) => void;
}
@@ -250,8 +257,10 @@ function ServiceUrlBehaviorMenuItem({
function GeneralSection({
settings,
+ workspaceOrganizationMode,
isDesktopApp,
handleSendBehaviorChange,
+ handleWorkspaceOrganizationModeChange,
handleServiceUrlBehaviorChange,
handleTerminalScrollbackLinesChange,
}: GeneralSectionProps) {
@@ -299,6 +308,20 @@ function GeneralSection({
options={SEND_BEHAVIOR_OPTIONS}
/>
+
+
+ Workspace organization
+
+ Choose whether projects show workspace rows or thread rows
+
+
+
+
{isDesktopApp ? (
@@ -1096,6 +1119,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const { theme } = useUnistyles();
const voiceAudioEngine = useVoiceAudioEngineOptional();
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
+ const workspaceOrganizationMode = useWorkspaceOrganizationStore((state) => state.mode);
+ const setWorkspaceOrganizationMode = useWorkspaceOrganizationStore((state) => state.setMode);
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
@@ -1149,6 +1174,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[updateSettings],
);
+ const handleWorkspaceOrganizationModeChange = setWorkspaceOrganizationMode;
+
const handleServiceUrlBehaviorChange = useCallback(
(behavior: ServiceUrlBehavior) => {
void updateSettings({ serviceUrlBehavior: behavior });
@@ -1359,8 +1386,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
return (
diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts
index efa3b333b2..78c7b76456 100644
--- a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts
+++ b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts
@@ -53,7 +53,7 @@ describe("workspace bulk close helpers", () => {
});
});
- it("describes mixed destructive bulk close operations in the confirmation copy", () => {
+ it("describes mixed bulk close operations in the confirmation copy", () => {
const message = buildBulkCloseConfirmationMessage(
classifyBulkClosableTabs([
makeAgentTab("a1"),
@@ -64,7 +64,7 @@ describe("workspace bulk close helpers", () => {
);
expect(message).toBe(
- "This will archive 2 agent(s), close 1 terminal(s), and close 1 tab(s). Any running process in a closed terminal will be stopped immediately.",
+ "This will close 2 thread tab(s), close 1 terminal(s), and close 1 tab(s). Any running process in a closed terminal will be stopped immediately.",
);
});
@@ -78,7 +78,18 @@ describe("workspace bulk close helpers", () => {
);
});
- it("closes all tabs immediately and fires one mixed closeItems RPC in the background", async () => {
+ it("describes workspace-first agent tab closes as archives", () => {
+ const message = buildBulkCloseConfirmationMessage(
+ classifyBulkClosableTabs([makeAgentTab("a1"), makeTerminalTab("t1")]),
+ { archiveAgentTabs: true },
+ );
+
+ expect(message).toBe(
+ "This will archive 1 agent(s) and close 1 terminal(s). Any running process in a closed terminal will be stopped immediately.",
+ );
+ });
+
+ it("closes all tabs immediately and fires one terminal closeItems RPC in the background", async () => {
const groups = classifyBulkClosableTabs([
makeAgentTab("a1"),
makeTerminalTab("t1"),
@@ -88,7 +99,7 @@ describe("workspace bulk close helpers", () => {
const closedTabIds: string[] = [];
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
const closeItems = vi.fn(async () => ({
- agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
+ agents: [],
terminals: [
{ terminalId: "t1", success: true },
{ terminalId: "t2", success: false },
@@ -99,6 +110,7 @@ describe("workspace bulk close helpers", () => {
await closeBulkWorkspaceTabs({
groups,
client: { closeItems },
+ archiveAgentTabs: false,
closeTab: async (tabId, action) => {
closedTabIds.push(tabId);
await action();
@@ -111,7 +123,7 @@ describe("workspace bulk close helpers", () => {
expect(closeItems).toHaveBeenCalledTimes(1);
expect(closeItems).toHaveBeenCalledWith({
- agentIds: ["a1"],
+ agentIds: [],
terminalIds: ["t1", "t2"],
});
expect(closedTabIds).toEqual([
@@ -145,6 +157,7 @@ describe("workspace bulk close helpers", () => {
throw new Error("rpc failed");
},
},
+ archiveAgentTabs: false,
closeTab: async (tabId, action) => {
closedTabIds.push(tabId);
await action();
@@ -166,4 +179,29 @@ describe("workspace bulk close helpers", () => {
{ tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
]);
});
+
+ it("archives agent tabs through closeItems when workspace-first bulk close requests it", async () => {
+ const groups = classifyBulkClosableTabs([makeAgentTab("a1"), makeTerminalTab("t1")]);
+ const closeItems = vi.fn(async () => ({
+ agents: [{ agentId: "a1", archivedAt: "2026-06-03T00:00:00.000Z" }],
+ terminals: [{ terminalId: "t1", success: true }],
+ requestId: "req-archive",
+ }));
+
+ await closeBulkWorkspaceTabs({
+ groups,
+ client: { closeItems },
+ archiveAgentTabs: true,
+ closeTab: async (_tabId, action) => {
+ await action();
+ },
+ closeWorkspaceTabWithCleanup: vi.fn(),
+ logLabel: "workspace-first",
+ });
+
+ expect(closeItems).toHaveBeenCalledWith({
+ agentIds: ["a1"],
+ terminalIds: ["t1"],
+ });
+ });
});
diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.ts b/packages/app/src/screens/workspace/workspace-bulk-close.ts
index a218fe1c78..814595bd76 100644
--- a/packages/app/src/screens/workspace/workspace-bulk-close.ts
+++ b/packages/app/src/screens/workspace/workspace-bulk-close.ts
@@ -15,6 +15,7 @@ interface CloseWorkspaceTabWithCleanupInput {
interface CloseBulkWorkspaceTabsInput {
client: Pick | null;
groups: BulkClosableTabGroups;
+ archiveAgentTabs: boolean;
closeTab: (tabId: string, action: () => Promise) => Promise;
closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void;
logLabel: string;
@@ -43,19 +44,24 @@ export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkCl
return groups;
}
-export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups): string {
+export function buildBulkCloseConfirmationMessage(
+ input: BulkClosableTabGroups,
+ options: { archiveAgentTabs?: boolean } = {},
+): string {
const { agentTabs, terminalTabs, otherTabs } = input;
+ const agentAction = options.archiveAgentTabs ? "archive" : "close";
+ const agentLabel = options.archiveAgentTabs ? "agent(s)" : "thread tab(s)";
if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) {
- return `This will archive ${agentTabs.length} agent(s), close ${terminalTabs.length} terminal(s), and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`;
+ return `This will ${agentAction} ${agentTabs.length} ${agentLabel}, close ${terminalTabs.length} terminal(s), and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`;
}
if (agentTabs.length > 0 && terminalTabs.length > 0) {
- return `This will archive ${agentTabs.length} agent(s) and close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`;
+ return `This will ${agentAction} ${agentTabs.length} ${agentLabel} and close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`;
}
if (terminalTabs.length > 0 && otherTabs.length > 0) {
return `This will close ${terminalTabs.length} terminal(s) and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`;
}
if (agentTabs.length > 0 && otherTabs.length > 0) {
- return `This will archive ${agentTabs.length} agent(s) and close ${otherTabs.length} tab(s).`;
+ return `This will ${agentAction} ${agentTabs.length} ${agentLabel} and close ${otherTabs.length} tab(s).`;
}
if (terminalTabs.length > 0) {
return `This will close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`;
@@ -63,17 +69,26 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups):
if (otherTabs.length > 0) {
return `This will close ${otherTabs.length} tab(s).`;
}
- return `This will archive ${agentTabs.length} agent(s).`;
+ return `This will ${agentAction} ${agentTabs.length} ${agentLabel}.`;
}
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise {
- const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
- const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
+ const {
+ client,
+ groups,
+ archiveAgentTabs,
+ closeTab,
+ closeWorkspaceTabWithCleanup,
+ logLabel,
+ warn,
+ } = input;
+ const hasDestructiveTabs =
+ (archiveAgentTabs && groups.agentTabs.length > 0) || groups.terminalTabs.length > 0;
if (hasDestructiveTabs && client) {
void client
.closeItems({
- agentIds: groups.agentTabs.map((tab) => tab.agentId),
+ agentIds: archiveAgentTabs ? groups.agentTabs.map((tab) => tab.agentId) : [],
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
})
.catch((error) => {
diff --git a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx
index fa353542f1..25cfdb4f8c 100644
--- a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx
+++ b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx
@@ -60,6 +60,7 @@ function buildContent(tab: WorkspaceTabDescriptor = agentTab) {
tab,
normalizedServerId: "server-a",
normalizedWorkspaceId: "workspace-a",
+ tabScopeKey: "server-a:project:project-a",
onOpenTab: vi.fn(),
onCloseCurrentTab: vi.fn(),
onRetargetCurrentTab: vi.fn(),
@@ -159,4 +160,17 @@ describe("WorkspacePaneContent", () => {
agentId: "agent-a",
});
});
+
+ it("uses the tab workspace context when it differs from the selected workspace", () => {
+ const content = buildContent({
+ key: "agent_agent-b",
+ tabId: "agent_agent-b",
+ kind: "agent",
+ target: { kind: "agent", agentId: "agent-b", workspaceId: "workspace-b" },
+ });
+
+ expect(content.key).toBe("server-a:workspace-b:agent_agent-b");
+ expect(content.paneContextValue.workspaceId).toBe("workspace-b");
+ expect(content.paneContextValue.tabScopeKey).toBe("server-a:project:project-a");
+ });
});
diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx
index fd40efeb08..16dab6dbde 100644
--- a/packages/app/src/screens/workspace/workspace-pane-content.tsx
+++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx
@@ -23,6 +23,7 @@ export interface BuildWorkspacePaneContentModelInput {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
+ tabScopeKey: string | null;
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
onCloseCurrentTab: () => void;
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
@@ -30,10 +31,29 @@ export interface BuildWorkspacePaneContentModelInput {
onOpenImportSheet: () => void;
}
+function trimNonEmpty(value: string | null | undefined): string | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+function resolveTabWorkspaceId(tab: WorkspaceTabDescriptor, fallbackWorkspaceId: string): string {
+ if (tab.target.kind === "setup") {
+ return tab.target.workspaceId;
+ }
+ if ("workspaceId" in tab.target) {
+ return trimNonEmpty(tab.target.workspaceId) ?? fallbackWorkspaceId;
+ }
+ return fallbackWorkspaceId;
+}
+
export function buildWorkspacePaneContentModel({
tab,
normalizedServerId,
normalizedWorkspaceId,
+ tabScopeKey,
onOpenTab,
onCloseCurrentTab,
onRetargetCurrentTab,
@@ -43,12 +63,14 @@ export function buildWorkspacePaneContentModel({
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
+ const tabWorkspaceId = resolveTabWorkspaceId(tab, normalizedWorkspaceId);
return {
- key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
+ key: `${normalizedServerId}:${tabWorkspaceId}:${tab.tabId}`,
Component: registration.component,
paneContextValue: {
serverId: normalizedServerId,
- workspaceId: normalizedWorkspaceId,
+ workspaceId: tabWorkspaceId,
+ tabScopeKey,
tabId: tab.tabId,
target: tab.target,
openTab: onOpenTab,
@@ -83,6 +105,7 @@ export function WorkspacePaneContent({
() => ({
serverId: paneContextValue.serverId,
workspaceId: paneContextValue.workspaceId,
+ tabScopeKey: paneContextValue.tabScopeKey,
tabId: paneContextValue.tabId,
target: paneContextValue.target,
openTab,
@@ -97,6 +120,7 @@ export function WorkspacePaneContent({
openImportSheet,
openTab,
paneContextValue.serverId,
+ paneContextValue.tabScopeKey,
paneContextValue.tabId,
paneContextValue.target,
paneContextValue.workspaceId,
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index 146514d1ff..e478772266 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -71,6 +71,7 @@ import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
+ buildWorkspaceProjectTabScopeKey,
buildWorkspaceTabPersistenceKey,
collectAllTabs,
getFocusedBrowserId,
@@ -142,6 +143,7 @@ import {
import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views";
import {
buildWorkspaceTabSnapshot,
+ deriveProjectAgentVisibility,
deriveWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual,
} from "@/workspace-tabs/agent-visibility";
@@ -162,7 +164,7 @@ import {
classifyBulkClosableTabs,
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
-import { resolveCloseAgentTabPolicy } from "@/subagents";
+import { resolveCloseAgentTabPolicy, shouldAutoOpenAgentTab } from "@/subagents";
import { findAdjacentPane } from "@/utils/split-navigation";
import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
@@ -178,6 +180,10 @@ import {
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { RenderProfile } from "@/utils/render-profiler";
+import {
+ getWorkspaceOrganizationPolicy,
+ useWorkspaceOrganizationStore,
+} from "@/stores/workspace-organization-store";
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
@@ -268,6 +274,32 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
+function getTargetWorkspaceId(target: WorkspaceTabTarget): string | null {
+ if (target.kind === "setup") {
+ return trimNonEmpty(target.workspaceId);
+ }
+ if ("workspaceId" in target) {
+ return trimNonEmpty(target.workspaceId);
+ }
+ return null;
+}
+
+function withWorkspaceTargetContext(
+ target: WorkspaceTabTarget,
+ workspaceId: string,
+): WorkspaceTabTarget {
+ if (target.kind === "setup") {
+ return target;
+ }
+ if (getTargetWorkspaceId(target)) {
+ return target;
+ }
+ return {
+ ...target,
+ workspaceId,
+ } as WorkspaceTabTarget;
+}
+
function decodeSegment(value: string): string {
try {
return decodeURIComponent(value);
@@ -1449,6 +1481,7 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
interface WorkspaceTerminalTabActionsInput {
persistenceKey: string | null;
+ workspaceId: string;
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
toast: {
@@ -1466,6 +1499,7 @@ interface WorkspaceTerminalTabActions {
function useWorkspaceTerminalTabActions({
persistenceKey,
+ workspaceId,
focusWorkspacePane,
openWorkspaceTabFocused,
toast,
@@ -1478,18 +1512,18 @@ function useWorkspaceTerminalTabActions({
if (paneId) {
focusWorkspacePane(persistenceKey, paneId);
}
- openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
+ openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId, workspaceId });
},
- [focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
+ [focusWorkspacePane, openWorkspaceTabFocused, persistenceKey, workspaceId],
);
const handleScriptTerminalSelected = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
- openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
+ openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId, workspaceId });
},
- [openWorkspaceTabFocused, persistenceKey],
+ [openWorkspaceTabFocused, persistenceKey, workspaceId],
);
const handleWorkspacePathUnavailable = useCallback(() => {
toast.error("Workspace path is not available yet");
@@ -1558,6 +1592,8 @@ function WorkspaceScreenContent({
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
+ const organizationMode = useWorkspaceOrganizationStore((state) => state.mode);
+ const organizationPolicy = getWorkspaceOrganizationPolicy(organizationMode);
const normalizedServerId = useMemo(() => trimNonEmpty(decodeSegment(serverId)) ?? "", [serverId]);
@@ -1604,7 +1640,7 @@ function WorkspaceScreenContent({
enabled: isRouteFocused,
});
- const persistenceKey = useMemo(
+ const workspacePersistenceKey = useMemo(
() =>
buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
@@ -1612,6 +1648,23 @@ function WorkspaceScreenContent({
}),
[normalizedServerId, normalizedWorkspaceId],
);
+ const projectTabScopeKey = useMemo(() => {
+ const projectKey =
+ workspaceDescriptor?.project?.projectKey ?? workspaceDescriptor?.projectId ?? null;
+ return (
+ buildWorkspaceProjectTabScopeKey({
+ serverId: normalizedServerId,
+ projectKey,
+ }) ?? workspacePersistenceKey
+ );
+ }, [
+ normalizedServerId,
+ workspaceDescriptor?.project?.projectKey,
+ workspaceDescriptor?.projectId,
+ workspacePersistenceKey,
+ ]);
+ const persistenceKey =
+ organizationPolicy.tabScope === "project" ? projectTabScopeKey : workspacePersistenceKey;
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const openWorkspaceChildTabFocused = useWorkspaceLayoutStore(
(state) => state.openChildTabFocused,
@@ -1623,14 +1676,46 @@ function WorkspaceScreenContent({
const workspaceAgentVisibility = useStoreWithEqualityFn(
useSessionStore,
- (state) =>
- deriveWorkspaceAgentVisibility({
- sessionAgents: state.sessions[normalizedServerId]?.agents,
- agentDetails: state.sessions[normalizedServerId]?.agentDetails,
+ (state) => {
+ const session = state.sessions[normalizedServerId];
+ if (organizationPolicy.agentVisibilityScope === "project") {
+ return deriveProjectAgentVisibility({
+ sessionAgents: session?.agents,
+ agentDetails: session?.agentDetails,
+ workspaces: session?.workspaces,
+ projectKey: workspaceDescriptor?.project?.projectKey ?? workspaceDescriptor?.projectId,
+ fallbackWorkspaceDirectory: workspaceDirectory,
+ });
+ }
+ return deriveWorkspaceAgentVisibility({
+ sessionAgents: session?.agents,
+ agentDetails: session?.agentDetails,
workspaceDirectory,
- }),
+ });
+ },
workspaceAgentVisibilityEqual,
);
+ const autoOpenAgentIds = useMemo(() => {
+ if (organizationPolicy.agentTabPopulation !== "auto-active") {
+ return EMPTY_SET;
+ }
+ const session = useSessionStore.getState().sessions[normalizedServerId];
+ if (!session) {
+ return EMPTY_SET;
+ }
+ const next = new Set();
+ for (const agentId of workspaceAgentVisibility.activeAgentIds) {
+ const agent = session.agents.get(agentId) ?? session.agentDetails.get(agentId) ?? null;
+ if (agent && shouldAutoOpenAgentTab(agent)) {
+ next.add(agentId);
+ }
+ }
+ return next;
+ }, [
+ normalizedServerId,
+ organizationPolicy.agentTabPopulation,
+ workspaceAgentVisibility.activeAgentIds,
+ ]);
const {
handleTerminalCreated,
@@ -1639,6 +1724,7 @@ function WorkspaceScreenContent({
handleTerminalCreateQueued,
} = useWorkspaceTerminalTabActions({
persistenceKey,
+ workspaceId: normalizedWorkspaceId,
focusWorkspacePane,
openWorkspaceTabFocused,
toast,
@@ -1675,7 +1761,6 @@ function WorkspaceScreenContent({
onTerminalCreateQueued: handleTerminalCreateQueued,
});
const { archiveAgent } = useArchiveAgent();
-
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({
client,
isConnected,
@@ -1784,7 +1869,7 @@ function WorkspaceScreenContent({
);
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
- persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null,
+ workspacePersistenceKey ? (state.snapshots[workspacePersistenceKey] ?? null) : null,
);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
const showWorkspaceSetup = shouldShowWorkspaceSetup(workspaceSetupSnapshot);
@@ -1886,6 +1971,7 @@ function WorkspaceScreenContent({
const target = normalizeWorkspaceTabTarget({
kind: "draft",
draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(),
+ workspaceId: normalizedWorkspaceId,
});
invariant(target?.kind === "draft", "Draft tab target must be valid");
if (input?.focus === false) {
@@ -1893,7 +1979,7 @@ function WorkspaceScreenContent({
}
return openWorkspaceTabFocused(persistenceKey, target);
},
- [openWorkspaceTabFocused, openWorkspaceTabInBackground, persistenceKey],
+ [normalizedWorkspaceId, openWorkspaceTabFocused, openWorkspaceTabInBackground, persistenceKey],
);
useEffect(() => {
@@ -1912,13 +1998,19 @@ function WorkspaceScreenContent({
return false;
}
const pending = pendingByDraftId[tab.target.draftId];
- return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
+ return (
+ pending?.serverId === normalizedServerId &&
+ getTargetWorkspaceId(tab.target) === normalizedWorkspaceId &&
+ pending.lifecycle === "active"
+ );
});
reconcileWorkspaceTabs(
persistenceKey,
buildWorkspaceTabSnapshot({
+ workspaceId: normalizedWorkspaceId,
agentVisibility: workspaceAgentVisibility,
+ autoOpenAgentIds,
agentsHydrated: hasHydratedAgents,
terminalsHydrated: terminalsQuery.isSuccess,
knownTerminalIds,
@@ -1932,6 +2024,7 @@ function WorkspaceScreenContent({
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
+ autoOpenAgentIds,
pendingByDraftId,
persistenceKey,
reconcileWorkspaceTabs,
@@ -1971,12 +2064,16 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
return;
}
- const tabId = openWorkspaceTabFocused(persistenceKey, { kind: "agent", agentId });
+ const tabId = openWorkspaceTabFocused(persistenceKey, {
+ kind: "agent",
+ agentId,
+ workspaceId: normalizedWorkspaceId,
+ });
if (tabId) {
navigateToTabId(tabId);
}
},
- [navigateToTabId, openWorkspaceTabFocused, persistenceKey],
+ [navigateToTabId, normalizedWorkspaceId, openWorkspaceTabFocused, persistenceKey],
);
const emptyWorkspaceSeedRef = useRef(null);
@@ -1987,17 +2084,17 @@ function WorkspaceScreenContent({
if (!isRouteFocused) {
return;
}
- if (!client || !normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
+ if (!client || !normalizedServerId || !normalizedWorkspaceId || !workspacePersistenceKey) {
return;
}
if (workspaceSetupSnapshot) {
return;
}
- if (requestedWorkspaceSetupStatusKeyRef.current === persistenceKey) {
+ if (requestedWorkspaceSetupStatusKeyRef.current === workspacePersistenceKey) {
return;
}
- requestedWorkspaceSetupStatusKeyRef.current = persistenceKey;
+ requestedWorkspaceSetupStatusKeyRef.current = workspacePersistenceKey;
let isCancelled = false;
client
@@ -2013,7 +2110,7 @@ function WorkspaceScreenContent({
return;
})
.catch(() => {
- if (requestedWorkspaceSetupStatusKeyRef.current === persistenceKey) {
+ if (requestedWorkspaceSetupStatusKeyRef.current === workspacePersistenceKey) {
requestedWorkspaceSetupStatusKeyRef.current = null;
}
});
@@ -2026,7 +2123,7 @@ function WorkspaceScreenContent({
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
- persistenceKey,
+ workspacePersistenceKey,
upsertWorkspaceSetupProgress,
workspaceSetupSnapshot,
]);
@@ -2077,7 +2174,7 @@ function WorkspaceScreenContent({
return;
}
if (!workspaceSetupSnapshot || !showWorkspaceSetup) {
- if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) {
+ if (autoOpenedSetupTabWorkspaceRef.current === workspacePersistenceKey) {
autoOpenedSetupTabWorkspaceRef.current = null;
}
return;
@@ -2091,10 +2188,10 @@ function WorkspaceScreenContent({
return;
}
if (hasSetupTab) {
- autoOpenedSetupTabWorkspaceRef.current = persistenceKey;
+ autoOpenedSetupTabWorkspaceRef.current = workspacePersistenceKey;
return;
}
- if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) {
+ if (autoOpenedSetupTabWorkspaceRef.current === workspacePersistenceKey) {
return;
}
@@ -2111,7 +2208,7 @@ function WorkspaceScreenContent({
return;
}
- autoOpenedSetupTabWorkspaceRef.current = persistenceKey;
+ autoOpenedSetupTabWorkspaceRef.current = workspacePersistenceKey;
}, [
hasSetupTab,
isRouteFocused,
@@ -2119,6 +2216,7 @@ function WorkspaceScreenContent({
openWorkspaceTabInBackground,
persistenceKey,
showWorkspaceSetup,
+ workspacePersistenceKey,
workspaceSetupSnapshot,
]);
@@ -2134,16 +2232,29 @@ function WorkspaceScreenContent({
if (!location) {
return;
}
- const tabId = openWorkspaceTabFocused(persistenceKey, createWorkspaceFileTabTarget(location));
+ const tabId = openWorkspaceTabFocused(persistenceKey, {
+ ...createWorkspaceFileTabTarget(location),
+ workspaceId: normalizedWorkspaceId,
+ });
if (tabId) {
navigateToTabId(tabId);
}
},
- [isMobile, navigateToTabId, openWorkspaceTabFocused, persistenceKey, showMobileAgent],
+ [
+ isMobile,
+ navigateToTabId,
+ normalizedWorkspaceId,
+ openWorkspaceTabFocused,
+ persistenceKey,
+ showMobileAgent,
+ ],
);
const handleOpenFileFromChat = useCallback(
- (location: WorkspaceFileLocation, options?: { parentTabId?: string | null }) => {
+ (
+ location: WorkspaceFileLocation,
+ options?: { parentTabId?: string | null; workspaceId?: string | null },
+ ) => {
const normalizedLocation = normalizeWorkspaceFileLocation(location);
if (!normalizedLocation) {
return;
@@ -2154,7 +2265,11 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
return;
}
- const target = createWorkspaceFileTabTarget(normalizedLocation);
+ const targetWorkspaceId = trimNonEmpty(options?.workspaceId) ?? normalizedWorkspaceId;
+ const target = {
+ ...createWorkspaceFileTabTarget(normalizedLocation),
+ workspaceId: targetWorkspaceId,
+ };
const tabId = options?.parentTabId
? openWorkspaceChildTabFocused(persistenceKey, target, options.parentTabId)
: openWorkspaceTabFocused(persistenceKey, target);
@@ -2165,6 +2280,7 @@ function WorkspaceScreenContent({
[
isMobile,
navigateToTabId,
+ normalizedWorkspaceId,
openWorkspaceChildTabFocused,
openWorkspaceTabFocused,
persistenceKey,
@@ -2177,17 +2293,25 @@ function WorkspaceScreenContent({
location: WorkspaceFileLocation;
sourcePaneId?: string;
parentTabId?: string | null;
+ workspaceId?: string | null;
}) => {
const location = normalizeWorkspaceFileLocation(input.location);
if (!location) {
return;
}
if (!persistenceKey || isMobile || !input.sourcePaneId) {
- handleOpenFileFromChat(location, { parentTabId: input.parentTabId });
+ handleOpenFileFromChat(location, {
+ parentTabId: input.parentTabId,
+ workspaceId: input.workspaceId,
+ });
return;
}
+ const targetWorkspaceId = trimNonEmpty(input.workspaceId) ?? normalizedWorkspaceId;
- const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location);
+ const target: WorkspaceTabTarget = {
+ ...createWorkspaceFileTabTarget(location),
+ workspaceId: targetWorkspaceId,
+ };
const placement = resolveSideFileOpenPlacement({
layout: workspaceLayout,
sourcePaneId: input.sourcePaneId,
@@ -2215,6 +2339,7 @@ function WorkspaceScreenContent({
isMobile,
focusWorkspacePane,
navigateToTabId,
+ normalizedWorkspaceId,
openWorkspaceChildTabFocused,
openWorkspaceTabFocused,
persistenceKey,
@@ -2229,11 +2354,13 @@ function WorkspaceScreenContent({
paneId,
parentTabId,
focusPaneBeforeOpen,
+ workspaceId: targetWorkspaceId,
}: {
request: WorkspaceFileOpenRequest;
paneId?: string | null;
parentTabId: string;
focusPaneBeforeOpen?: boolean;
+ workspaceId?: string | null;
}) {
if (focusPaneBeforeOpen && paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, paneId);
@@ -2243,10 +2370,11 @@ function WorkspaceScreenContent({
location: request.location,
sourcePaneId: paneId ?? undefined,
parentTabId,
+ workspaceId: targetWorkspaceId,
});
return;
}
- handleOpenFileFromChat(request.location, { parentTabId });
+ handleOpenFileFromChat(request.location, { parentTabId, workspaceId: targetWorkspaceId });
});
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState(null);
@@ -2313,9 +2441,13 @@ function WorkspaceScreenContent({
focusWorkspacePane(persistenceKey, input.paneId);
}
const { browserId } = createWorkspaceBrowser();
- openWorkspaceTabFocused(persistenceKey, { kind: "browser", browserId });
+ openWorkspaceTabFocused(persistenceKey, {
+ kind: "browser",
+ browserId,
+ workspaceId: normalizedWorkspaceId,
+ });
},
- [focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
+ [focusWorkspacePane, normalizedWorkspaceId, openWorkspaceTabFocused, persistenceKey],
);
const handleOpenUrlInBrowserTab = useCallback(
@@ -2324,9 +2456,13 @@ function WorkspaceScreenContent({
return;
}
const { browserId } = createWorkspaceBrowser({ initialUrl: url });
- openWorkspaceTabFocused(persistenceKey, { kind: "browser", browserId });
+ openWorkspaceTabFocused(persistenceKey, {
+ kind: "browser",
+ browserId,
+ workspaceId: normalizedWorkspaceId,
+ });
},
- [openWorkspaceTabFocused, persistenceKey],
+ [normalizedWorkspaceId, openWorkspaceTabFocused, persistenceKey],
);
const handleSelectSwitcherTab = useCallback(
@@ -2401,7 +2537,10 @@ function WorkspaceScreenContent({
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
- const closePolicy = resolveCloseAgentTabPolicy(agent);
+ const closePolicy =
+ organizationPolicy.agentTabClose === "archive-root"
+ ? resolveCloseAgentTabPolicy(agent)
+ : { kind: "layout-only" as const };
const isRunning = agent?.status === "running";
if (isRunning && closePolicy.kind === "archive-on-close") {
@@ -2430,11 +2569,17 @@ function WorkspaceScreenContent({
return;
}
- // Errors (e.g. timeout) are handled by the mutation's onSettled callback
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
});
},
- [archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
+ [
+ archiveAgent,
+ closeTab,
+ closeWorkspaceTabWithCleanup,
+ normalizedServerId,
+ organizationPolicy.agentTabClose,
+ persistenceKey,
+ ],
);
const handleCloseDraftOrFileTab = useCallback(
@@ -2595,9 +2740,10 @@ function WorkspaceScreenContent({
}
const groups = classifyBulkClosableTabs(tabsToClose);
+ const archiveAgentTabs = organizationPolicy.agentTabClose === "archive-root";
const confirmed = await confirmDialog({
title,
- message: buildBulkCloseConfirmationMessage(groups),
+ message: buildBulkCloseConfirmationMessage(groups, { archiveAgentTabs }),
confirmLabel: "Close",
cancelLabel: "Cancel",
destructive: true,
@@ -2609,6 +2755,7 @@ function WorkspaceScreenContent({
await closeBulkWorkspaceTabs({
client,
groups,
+ archiveAgentTabs,
closeTab,
closeWorkspaceTabWithCleanup: (cleanupInput) => {
if (!persistenceKey) {
@@ -2625,7 +2772,13 @@ function WorkspaceScreenContent({
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
- [client, closeTab, closeWorkspaceTabWithCleanup, persistenceKey],
+ [
+ client,
+ closeTab,
+ closeWorkspaceTabWithCleanup,
+ organizationPolicy.agentTabClose,
+ persistenceKey,
+ ],
);
const handleCloseTabsToLeftInPane = useCallback(
@@ -2903,11 +3056,13 @@ function WorkspaceScreenContent({
tab: WorkspaceTabDescriptor;
paneId?: string | null;
focusPaneBeforeOpen?: boolean;
- }) =>
- buildWorkspacePaneContentModel({
+ }) => {
+ const paneWorkspaceId = getTargetWorkspaceId(input.tab.target) ?? normalizedWorkspaceId;
+ return buildWorkspacePaneContentModel({
tab: input.tab,
normalizedServerId,
normalizedWorkspaceId,
+ tabScopeKey: persistenceKey,
onOpenTab: (target) => {
if (!persistenceKey) {
return;
@@ -2915,7 +3070,11 @@ function WorkspaceScreenContent({
if (input.focusPaneBeforeOpen && input.paneId) {
focusWorkspacePane(persistenceKey, input.paneId);
}
- const tabId = openWorkspaceChildTabFocused(persistenceKey, target, input.tab.tabId);
+ const tabId = openWorkspaceChildTabFocused(
+ persistenceKey,
+ withWorkspaceTargetContext(target, paneWorkspaceId),
+ input.tab.tabId,
+ );
if (tabId) {
navigateToTabId(tabId);
}
@@ -2927,7 +3086,11 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
return;
}
- retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
+ retargetWorkspaceTab(
+ persistenceKey,
+ input.tab.tabId,
+ withWorkspaceTargetContext(target, paneWorkspaceId),
+ );
},
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
handleOpenWorkspaceFileFromPane({
@@ -2935,10 +3098,12 @@ function WorkspaceScreenContent({
paneId: input.paneId,
parentTabId: input.tab.tabId,
focusPaneBeforeOpen: input.focusPaneBeforeOpen,
+ workspaceId: paneWorkspaceId,
});
},
onOpenImportSheet: openImportSheet,
- }),
+ });
+ },
[
handleCloseTabById,
focusWorkspacePane,
diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts
index cb2a72d20d..8bc0976d5d 100644
--- a/packages/app/src/stores/session-store-hooks/index.ts
+++ b/packages/app/src/stores/session-store-hooks/index.ts
@@ -7,6 +7,7 @@ import {
selectProjectOrder,
selectRecommendedProjectPaths,
selectResolveWorkspaceIdByCwd,
+ selectSidebarAgentWorkspaces,
selectWorkspace,
selectWorkspaceExecutionAuthority,
selectWorkspaceFields,
@@ -15,6 +16,7 @@ import {
selectWorkspaceStatusesForBadges,
selectWorkspaceStructureProjects,
workspaceEqualityFns,
+ type SidebarAgentWorkspaceSource,
type WorkspaceStructure,
} from "./selectors";
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
@@ -27,6 +29,7 @@ import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
export type {
DesktopBadgeWorkspaceStatus,
+ SidebarAgentWorkspaceSource,
WorkspaceStructure,
WorkspaceStructureProject,
} from "./selectors";
@@ -94,6 +97,14 @@ export function useWorkspaceStructure(serverId: string | null): WorkspaceStructu
);
}
+export function useSidebarAgentWorkspaces(serverId: string | null): SidebarAgentWorkspaceSource[] {
+ return useStoreWithEqualityFn(
+ useSessionStore,
+ (state) => selectSidebarAgentWorkspaces(state, serverId),
+ workspaceEqualityFns.deep,
+ );
+}
+
export function useWorkspaceKeys(serverId: string | null): string[] {
return useStoreWithEqualityFn(
useSessionStore,
diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts
index 5be68fe843..50bbe40823 100644
--- a/packages/app/src/stores/session-store-hooks/selectors.test.ts
+++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts
@@ -38,6 +38,21 @@ function createWorkspace(
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
+ gitRuntime: input.gitRuntime,
+ githubRuntime: input.githubRuntime,
+ project: input.project,
+ };
+}
+
+function gitRuntime(currentBranch: string): NonNullable {
+ return {
+ currentBranch,
+ remoteUrl: null,
+ isPaseoOwnedWorktree: false,
+ isDirty: false,
+ aheadBehind: null,
+ aheadOfOrigin: null,
+ behindOfOrigin: null,
};
}
@@ -236,6 +251,48 @@ describe("workspace structure composition", () => {
tracked.stop();
});
+ it("includes workspace branch details for sidebar structural rows", () => {
+ const workspace = createWorkspace({
+ id: "/Users/ethan/paseo",
+ name: "/Users/ethan/paseo",
+ workspaceDirectory: "/Users/ethan/paseo",
+ gitRuntime: gitRuntime("feature/sidebar"),
+ });
+ initializeWorkspaces([workspace]);
+
+ const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), SERVER_ID);
+
+ expect(projects[0]?.workspaceDetailsById?.["/Users/ethan/paseo"]).toEqual({
+ workspaceId: "/Users/ethan/paseo",
+ workspaceName: "/Users/ethan/paseo",
+ workspaceDirectory: "/Users/ethan/paseo",
+ workspaceKind: "local_checkout",
+ currentBranch: "feature/sidebar",
+ });
+ });
+
+ it("changes when a workspace branch changes", () => {
+ const workspace = createWorkspace({
+ id: "workspace-a",
+ gitRuntime: gitRuntime("main"),
+ });
+ initializeWorkspaces([workspace]);
+
+ const tracked = trackSelector(
+ useSessionStore,
+ (state) => selectWorkspaceStructureProjects(state, SERVER_ID),
+ workspaceEqualityFns.deep,
+ );
+ const before = tracked.current;
+
+ useSessionStore
+ .getState()
+ .mergeWorkspaces(SERVER_ID, [{ ...workspace, gitRuntime: gitRuntime("feature/sidebar") }]);
+ expect(tracked.current).not.toBe(before);
+
+ tracked.stop();
+ });
+
it("changes when a structure-relevant project identity field changes", () => {
const workspace = createWorkspace({
id: "workspace-a",
diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts
index 8dd402832e..3fb7dd3595 100644
--- a/packages/app/src/stores/session-store-hooks/selectors.ts
+++ b/packages/app/src/stores/session-store-hooks/selectors.ts
@@ -5,6 +5,7 @@ import {
type WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
+import type { SidebarAgentWorkspaceSource } from "@/hooks/sidebar-workspaces-view-model";
import {
getWorkspaceExecutionAuthority,
resolveWorkspaceIdByExecutionDirectory,
@@ -14,6 +15,7 @@ import {
import type { WorkspaceDescriptor } from "../session-store";
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
+export type { SidebarAgentWorkspaceSource } from "@/hooks/sidebar-workspaces-view-model";
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
export interface SessionsSnapshot {
@@ -27,6 +29,7 @@ export interface SidebarOrderSnapshot {
const EMPTY_WORKSPACE_KEYS: string[] = [];
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
+const EMPTY_SIDEBAR_AGENT_WORKSPACES: SidebarAgentWorkspaceSource[] = [];
export const workspaceEqualityFns = {
identity: Object.is as (a: unknown, b: unknown) => boolean,
@@ -140,6 +143,34 @@ export function selectWorkspaceStructureProjects(
return buildWorkspaceStructureProjects({ serverId, workspaces: workspaces.values() });
}
+export function selectSidebarAgentWorkspaces(
+ state: SessionsSnapshot,
+ serverId: string | null,
+): SidebarAgentWorkspaceSource[] {
+ if (!serverId) {
+ return EMPTY_SIDEBAR_AGENT_WORKSPACES;
+ }
+
+ const workspaces = state.sessions[serverId]?.workspaces;
+ if (!workspaces || workspaces.size === 0) {
+ return EMPTY_SIDEBAR_AGENT_WORKSPACES;
+ }
+
+ return Array.from(workspaces.values()).map((workspace) => ({
+ id: workspace.id,
+ projectId: workspace.projectId,
+ projectRootPath: workspace.projectRootPath,
+ workspaceDirectory: workspace.workspaceDirectory,
+ projectKind: workspace.projectKind,
+ workspaceKind: workspace.workspaceKind,
+ name: workspace.name,
+ gitRuntime: workspace.gitRuntime
+ ? { currentBranch: workspace.gitRuntime.currentBranch }
+ : undefined,
+ project: workspace.project,
+ }));
+}
+
export function selectProjectOrder(state: SidebarOrderSnapshot, serverId: string | null): string[] {
return serverId
? (state.projectOrderByServerId[serverId] ?? EMPTY_WORKSPACE_KEYS)
diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts
index 86cba617c2..61e1a2ee4d 100644
--- a/packages/app/src/stores/workspace-layout-actions.ts
+++ b/packages/app/src/stores/workspace-layout-actions.ts
@@ -198,10 +198,11 @@ export interface WorkspaceTabReconcileState {
}
export interface WorkspaceTabSnapshot {
+ workspaceId?: string | null;
agentsHydrated: boolean;
terminalsHydrated: boolean;
activeAgentIds: Iterable;
- autoOpenAgentIds: Iterable;
+ autoOpenAgentIds?: Iterable;
knownAgentIds: Iterable;
knownTerminalIds?: Iterable;
standaloneTerminalIds: Iterable;
@@ -1562,13 +1563,13 @@ function isEntityTarget(
function isAgentTab(
tab: WorkspaceTab,
-): tab is WorkspaceTab & { target: { kind: "agent"; agentId: string } } {
+): tab is WorkspaceTab & { target: { kind: "agent"; agentId: string; workspaceId?: string } } {
return tab.target.kind === "agent";
}
-function isTerminalTab(
- tab: WorkspaceTab,
-): tab is WorkspaceTab & { target: { kind: "terminal"; terminalId: string } } {
+function isTerminalTab(tab: WorkspaceTab): tab is WorkspaceTab & {
+ target: { kind: "terminal"; terminalId: string; workspaceId?: string };
+} {
return tab.target.kind === "terminal";
}
@@ -1608,12 +1609,11 @@ function applyPinnedAndHidden(input: {
baseAgentIds: Set;
pinnedAgentIds: Set;
hiddenAgentIds: Set;
- knownAgentIds: Set;
}): Set {
- const { baseAgentIds, pinnedAgentIds, hiddenAgentIds, knownAgentIds } = input;
+ const { baseAgentIds, pinnedAgentIds, hiddenAgentIds } = input;
const result = new Set(baseAgentIds);
for (const agentId of pinnedAgentIds) {
- if (knownAgentIds.has(agentId)) {
+ if (baseAgentIds.has(agentId)) {
result.add(agentId);
}
}
@@ -1651,12 +1651,17 @@ function collapseStaleEntityTabs(input: {
layout: WorkspaceLayout;
snapshot: WorkspaceTabSnapshot;
visibleAgentIds: Set;
+ knownAgentIds: Set;
knownTerminalIds: Set;
}): WorkspaceLayout {
- const { snapshot, visibleAgentIds, knownTerminalIds } = input;
+ const { snapshot, visibleAgentIds, knownAgentIds, knownTerminalIds } = input;
+ const snapshotWorkspaceId = trimNonEmpty(snapshot.workspaceId);
let nextLayout = input.layout;
for (const tab of collectAllTabs(nextLayout.root)) {
if (isAgentTab(tab) && snapshot.agentsHydrated && !visibleAgentIds.has(tab.target.agentId)) {
+ if (tab.target.allowArchived === true && knownAgentIds.has(tab.target.agentId)) {
+ continue;
+ }
nextLayout =
closeTabInLayout({
layout: nextLayout,
@@ -1666,6 +1671,9 @@ function collapseStaleEntityTabs(input: {
if (
isTerminalTab(tab) &&
snapshot.terminalsHydrated &&
+ (!snapshotWorkspaceId ||
+ !tab.target.workspaceId ||
+ tab.target.workspaceId === snapshotWorkspaceId) &&
!knownTerminalIds.has(tab.target.terminalId)
) {
nextLayout =
@@ -1680,6 +1688,7 @@ function collapseStaleEntityTabs(input: {
function addMissingEntityTabs(input: {
layout: WorkspaceLayout;
+ workspaceId: string | null;
autoOpenAgentIds: Set;
representedAgentIds: Set;
standaloneTerminalIds: Set;
@@ -1689,6 +1698,7 @@ function addMissingEntityTabs(input: {
autoOpenAgentIds,
representedAgentIds,
standaloneTerminalIds,
+ workspaceId,
hasActivePendingDraftCreate,
} = input;
let nextLayout = input.layout;
@@ -1711,6 +1721,7 @@ function addMissingEntityTabs(input: {
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
kind: "agent",
agentId,
+ ...(workspaceId ? { workspaceId } : {}),
});
currentAgentIds.add(agentId);
}
@@ -1723,6 +1734,7 @@ function addMissingEntityTabs(input: {
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
kind: "terminal",
terminalId,
+ ...(workspaceId ? { workspaceId } : {}),
});
currentTerminalIds.add(terminalId);
}
@@ -1740,8 +1752,8 @@ export function reconcileWorkspaceTabs(
const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []);
const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []);
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
- const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds);
const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds);
+ const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds ?? []);
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
const knownTerminalIds = snapshot.knownTerminalIds
? normalizeStringSet(snapshot.knownTerminalIds)
@@ -1750,20 +1762,17 @@ export function reconcileWorkspaceTabs(
baseAgentIds: activeAgentIds,
pinnedAgentIds,
hiddenAgentIds,
- knownAgentIds,
});
const autoOpenSet = applyPinnedAndHidden({
baseAgentIds: autoOpenAgentIds,
pinnedAgentIds,
hiddenAgentIds,
- knownAgentIds,
});
const initialTabs = collectAllTabs(nextLayout.root);
const representedAgentIds = new Set(
initialTabs.filter(isAgentTab).map((tab) => tab.target.agentId),
);
-
const entityGroups = buildEntityTabGroups(initialTabs);
for (const [canonicalTabId, group] of entityGroups) {
@@ -1801,11 +1810,13 @@ export function reconcileWorkspaceTabs(
layout: nextLayout,
snapshot,
visibleAgentIds,
+ knownAgentIds,
knownTerminalIds,
});
nextLayout = addMissingEntityTabs({
layout: nextLayout,
+ workspaceId: trimNonEmpty(snapshot.workspaceId),
autoOpenAgentIds: autoOpenSet,
representedAgentIds,
standaloneTerminalIds,
diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts
index 546016b267..ad6c4f6f4e 100644
--- a/packages/app/src/stores/workspace-layout-store.test.ts
+++ b/packages/app/src/stores/workspace-layout-store.test.ts
@@ -1330,7 +1330,6 @@ describe("workspace-layout-store actions", () => {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
- autoOpenAgentIds: ["agent-1"],
knownAgentIds: ["agent-1", "agent-2"],
standaloneTerminalIds: ["term-1"],
hasActivePendingDraftCreate: false,
@@ -1339,12 +1338,7 @@ describe("workspace-layout-store actions", () => {
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tabs = collectAllTabs(layout.root);
- expect(tabs.map((tab) => tab.tabId)).toEqual([
- "agent_agent-1",
- "draft-1",
- "agent_agent-2",
- "terminal_term-1",
- ]);
+ expect(tabs.map((tab) => tab.tabId)).toEqual(["agent_agent-1", "draft-1", "terminal_term-1"]);
expect(tabs.find((tab) => tab.tabId === "agent_agent-1")).toEqual({
tabId: "agent_agent-1",
target: { kind: "agent", agentId: "agent-1" },
@@ -1386,7 +1380,6 @@ describe("workspace-layout-store actions", () => {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
- autoOpenAgentIds: ["agent-1"],
knownAgentIds: ["agent-1"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
@@ -1418,7 +1411,6 @@ describe("workspace-layout-store actions", () => {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
- autoOpenAgentIds: ["agent-1"],
knownAgentIds: ["agent-1"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
@@ -1427,14 +1419,13 @@ describe("workspace-layout-store actions", () => {
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
});
- it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
+ it("reconcileTabs does not auto-open active agent tabs", () => {
const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["parent-agent", "child-agent"],
- autoOpenAgentIds: ["parent-agent"],
knownAgentIds: ["parent-agent", "child-agent"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
@@ -1445,10 +1436,85 @@ describe("workspace-layout-store actions", () => {
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
- ).toEqual(["agent_parent-agent"]);
+ ).toEqual([]);
+ });
+
+ it("reconcileTabs auto-opens agent tabs when the snapshot requests workspace-mode population", () => {
+ const workspaceKey = createWorkspaceKey();
+
+ workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
+ workspaceId: WORKSPACE_ID,
+ agentsHydrated: true,
+ terminalsHydrated: true,
+ activeAgentIds: ["parent-agent", "child-agent"],
+ autoOpenAgentIds: ["parent-agent"],
+ knownAgentIds: ["parent-agent", "child-agent"],
+ standaloneTerminalIds: [],
+ hasActivePendingDraftCreate: false,
+ });
+
+ expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([
+ {
+ tabId: "agent_parent-agent",
+ target: { kind: "agent", agentId: "parent-agent", workspaceId: WORKSPACE_ID },
+ createdAt: expect.any(Number),
+ },
+ ]);
});
- it("reconcileTabs keeps manually opened subagent tabs that remain active", () => {
+ it("reconcileTabs prunes pinned archived agent tabs because archive state is authoritative", () => {
+ const workspaceKey = createWorkspaceKey();
+ const store = workspaceLayoutStore.getState();
+
+ store.pinAgent(workspaceKey, "archived-agent");
+ store.openTabFocused(workspaceKey, { kind: "agent", agentId: "archived-agent" });
+
+ store.reconcileTabs(workspaceKey, {
+ agentsHydrated: true,
+ terminalsHydrated: true,
+ activeAgentIds: [],
+ knownAgentIds: ["archived-agent"],
+ standaloneTerminalIds: [],
+ hasActivePendingDraftCreate: false,
+ });
+
+ expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
+ });
+
+ it("reconcileTabs keeps explicitly reopened archived agent viewer tabs", () => {
+ const workspaceKey = createWorkspaceKey();
+ const store = workspaceLayoutStore.getState();
+
+ store.pinAgent(workspaceKey, "archived-agent");
+ store.openTabFocused(workspaceKey, {
+ kind: "agent",
+ agentId: "archived-agent",
+ allowArchived: true,
+ });
+
+ store.reconcileTabs(workspaceKey, {
+ agentsHydrated: true,
+ terminalsHydrated: true,
+ activeAgentIds: [],
+ knownAgentIds: ["archived-agent"],
+ standaloneTerminalIds: [],
+ hasActivePendingDraftCreate: false,
+ });
+
+ expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([
+ {
+ tabId: "agent_archived-agent",
+ target: {
+ kind: "agent",
+ agentId: "archived-agent",
+ allowArchived: true,
+ },
+ createdAt: expect.any(Number),
+ },
+ ]);
+ });
+
+ it("reconcileTabs keeps manually opened agent tabs that remain active", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
@@ -1458,7 +1524,6 @@ describe("workspace-layout-store actions", () => {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["parent-agent", "child-agent"],
- autoOpenAgentIds: ["parent-agent"],
knownAgentIds: ["parent-agent", "child-agent"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
@@ -1469,7 +1534,7 @@ describe("workspace-layout-store actions", () => {
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
- ).toEqual(["agent_child-agent", "agent_parent-agent"]);
+ ).toEqual(["agent_child-agent"]);
});
it("reconcileTabs prunes archived subagent tabs that are no longer active", () => {
@@ -1482,7 +1547,6 @@ describe("workspace-layout-store actions", () => {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["parent-agent"],
- autoOpenAgentIds: ["parent-agent"],
knownAgentIds: ["parent-agent", "child-agent"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
@@ -1493,7 +1557,7 @@ describe("workspace-layout-store actions", () => {
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
- ).toEqual(["agent_parent-agent"]);
+ ).toEqual([]);
});
it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => {
@@ -1505,7 +1569,6 @@ describe("workspace-layout-store actions", () => {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["child-agent"],
- autoOpenAgentIds: [],
knownAgentIds: ["child-agent"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
@@ -1535,7 +1598,6 @@ describe("workspace-layout-store actions", () => {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
- autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: ["term-script", "term-manual"],
standaloneTerminalIds: ["term-manual"],
@@ -1548,6 +1610,86 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
});
+ it("reconcileTabs keeps terminal tabs from sibling workspaces in a shared project scope", () => {
+ const workspaceKey = createWorkspaceKey();
+
+ workspaceLayoutStore.setState((state) => ({
+ ...state,
+ layoutByWorkspace: {
+ ...state.layoutByWorkspace,
+ [workspaceKey]: {
+ root: {
+ kind: "pane",
+ pane: {
+ id: "main",
+ tabIds: [
+ "terminal_term-current",
+ "terminal_term-sibling",
+ "terminal_term-stale-current",
+ ],
+ focusedTabId: "terminal_term-sibling",
+ tabs: [
+ {
+ tabId: "terminal_term-current",
+ target: {
+ kind: "terminal",
+ terminalId: "term-current",
+ workspaceId: WORKSPACE_ID,
+ },
+ createdAt: 1,
+ },
+ {
+ tabId: "terminal_term-sibling",
+ target: {
+ kind: "terminal",
+ terminalId: "term-sibling",
+ workspaceId: "ws-sibling",
+ },
+ createdAt: 2,
+ },
+ {
+ tabId: "terminal_term-stale-current",
+ target: {
+ kind: "terminal",
+ terminalId: "term-stale-current",
+ workspaceId: WORKSPACE_ID,
+ },
+ createdAt: 3,
+ },
+ ],
+ } as SplitPane,
+ },
+ focusedPaneId: "main",
+ },
+ },
+ }));
+
+ workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
+ workspaceId: WORKSPACE_ID,
+ agentsHydrated: true,
+ terminalsHydrated: true,
+ activeAgentIds: [],
+ knownAgentIds: [],
+ knownTerminalIds: ["term-current"],
+ standaloneTerminalIds: [],
+ hasActivePendingDraftCreate: false,
+ });
+
+ const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
+ const tabs = collectAllTabs(layout.root);
+
+ expect(tabs.map((tab) => tab.tabId)).toEqual([
+ "terminal_term-current",
+ "terminal_term-sibling",
+ ]);
+ expect(tabs.find((tab) => tab.tabId === "terminal_term-sibling")?.target).toEqual({
+ kind: "terminal",
+ terminalId: "term-sibling",
+ workspaceId: "ws-sibling",
+ });
+ expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("terminal_term-sibling");
+ });
+
it("reconcileTabs does not auto-open live non-standalone terminals", () => {
const workspaceKey = createWorkspaceKey();
@@ -1555,7 +1697,6 @@ describe("workspace-layout-store actions", () => {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
- autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: ["term-script"],
standaloneTerminalIds: [],
diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts
index 673fc210a4..a2d9fa6cf9 100644
--- a/packages/app/src/stores/workspace-layout-store.ts
+++ b/packages/app/src/stores/workspace-layout-store.ts
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
+ buildWorkspaceProjectTabScopeKey,
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
type WorkspaceTabTarget,
@@ -46,7 +47,7 @@ import {
} from "@/stores/workspace-layout-actions";
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
-export { buildWorkspaceTabPersistenceKey };
+export { buildWorkspaceProjectTabScopeKey, buildWorkspaceTabPersistenceKey };
export {
collectAllPanes,
collectAllTabs,
diff --git a/packages/app/src/stores/workspace-organization-store.test.ts b/packages/app/src/stores/workspace-organization-store.test.ts
new file mode 100644
index 0000000000..7d90ee93fd
--- /dev/null
+++ b/packages/app/src/stores/workspace-organization-store.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@react-native-async-storage/async-storage", () => ({
+ default: {
+ getItem: vi.fn().mockResolvedValue(null),
+ setItem: vi.fn().mockResolvedValue(undefined),
+ removeItem: vi.fn().mockResolvedValue(undefined),
+ },
+}));
+
+import {
+ DEFAULT_WORKSPACE_ORGANIZATION_MODE,
+ getWorkspaceOrganizationPolicy,
+} from "@/stores/workspace-organization-store";
+
+describe("workspace organization policy", () => {
+ it("defaults to workspace-first production behavior", () => {
+ expect(DEFAULT_WORKSPACE_ORGANIZATION_MODE).toBe("workspace-first");
+ expect(getWorkspaceOrganizationPolicy(DEFAULT_WORKSPACE_ORGANIZATION_MODE)).toEqual({
+ sidebarMode: "workspaces",
+ tabScope: "workspace",
+ agentVisibilityScope: "workspace",
+ agentTabClose: "archive-root",
+ agentTabPopulation: "auto-active",
+ sidebarShortcutScope: "workspaces",
+ });
+ });
+
+ it("keeps thread-first behavior opt-in", () => {
+ expect(getWorkspaceOrganizationPolicy("thread-first")).toEqual({
+ sidebarMode: "threads",
+ tabScope: "project",
+ agentVisibilityScope: "project",
+ agentTabClose: "layout-only",
+ agentTabPopulation: "manual-open",
+ sidebarShortcutScope: "none",
+ });
+ });
+});
diff --git a/packages/app/src/stores/workspace-organization-store.ts b/packages/app/src/stores/workspace-organization-store.ts
new file mode 100644
index 0000000000..9d9a4228fb
--- /dev/null
+++ b/packages/app/src/stores/workspace-organization-store.ts
@@ -0,0 +1,86 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+export type WorkspaceOrganizationMode = "workspace-first" | "thread-first";
+
+export interface WorkspaceOrganizationPolicy {
+ sidebarMode: "workspaces" | "threads";
+ tabScope: "workspace" | "project";
+ agentVisibilityScope: "workspace" | "project";
+ agentTabClose: "archive-root" | "layout-only";
+ agentTabPopulation: "auto-active" | "manual-open";
+ sidebarShortcutScope: "workspaces" | "none";
+}
+
+export const DEFAULT_WORKSPACE_ORGANIZATION_MODE: WorkspaceOrganizationMode = "workspace-first";
+
+export const WORKSPACE_ORGANIZATION_MODE_OPTIONS: Array<{
+ value: WorkspaceOrganizationMode;
+ label: string;
+}> = [
+ { value: "workspace-first", label: "Workspaces" },
+ { value: "thread-first", label: "Threads" },
+];
+
+const WORKSPACE_FIRST_POLICY: WorkspaceOrganizationPolicy = {
+ sidebarMode: "workspaces",
+ tabScope: "workspace",
+ agentVisibilityScope: "workspace",
+ agentTabClose: "archive-root",
+ agentTabPopulation: "auto-active",
+ sidebarShortcutScope: "workspaces",
+};
+
+const THREAD_FIRST_POLICY: WorkspaceOrganizationPolicy = {
+ sidebarMode: "threads",
+ tabScope: "project",
+ agentVisibilityScope: "project",
+ agentTabClose: "layout-only",
+ agentTabPopulation: "manual-open",
+ sidebarShortcutScope: "none",
+};
+
+interface WorkspaceOrganizationStoreState {
+ mode: WorkspaceOrganizationMode;
+ setMode: (mode: WorkspaceOrganizationMode) => void;
+}
+
+function normalizeWorkspaceOrganizationMode(value: unknown): WorkspaceOrganizationMode {
+ return value === "thread-first" ? "thread-first" : DEFAULT_WORKSPACE_ORGANIZATION_MODE;
+}
+
+export function getWorkspaceOrganizationPolicy(
+ mode: WorkspaceOrganizationMode,
+): WorkspaceOrganizationPolicy {
+ return mode === "thread-first" ? THREAD_FIRST_POLICY : WORKSPACE_FIRST_POLICY;
+}
+
+export const useWorkspaceOrganizationStore = create()(
+ persist(
+ (set) => ({
+ mode: DEFAULT_WORKSPACE_ORGANIZATION_MODE,
+ setMode: (mode) => {
+ set({ mode: normalizeWorkspaceOrganizationMode(mode) });
+ },
+ }),
+ {
+ name: "workspace-organization-mode",
+ version: 1,
+ storage: createJSONStorage(() => AsyncStorage),
+ partialize: (state) => ({
+ mode: state.mode,
+ }),
+ merge: (persistedState, currentState) => {
+ const persisted =
+ persistedState && typeof persistedState === "object"
+ ? (persistedState as { mode?: unknown })
+ : null;
+ return {
+ ...currentState,
+ mode: normalizeWorkspaceOrganizationMode(persisted?.mode),
+ };
+ },
+ },
+ ),
+);
diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts
index 4219b5203f..6bf3777e44 100644
--- a/packages/app/src/stores/workspace-subagents-integration.test.ts
+++ b/packages/app/src/stores/workspace-subagents-integration.test.ts
@@ -127,7 +127,7 @@ afterEach(() => {
});
describe("workspace subagents integration", () => {
- it("keeps a child ingested before its parent out of auto-tabs, then exposes it in the parent section", () => {
+ it("keeps child and parent hydration out of auto-tabs while preserving parent selection", () => {
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
@@ -154,7 +154,7 @@ describe("workspace subagents integration", () => {
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
- expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
+ expect(getWorkspaceTabIds(workspaceKey!)).toEqual([]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
diff --git a/packages/app/src/stores/workspace-tabs-store/index.ts b/packages/app/src/stores/workspace-tabs-store/index.ts
index 013f3fca42..53ff23966b 100644
--- a/packages/app/src/stores/workspace-tabs-store/index.ts
+++ b/packages/app/src/stores/workspace-tabs-store/index.ts
@@ -19,7 +19,7 @@ import {
type WorkspaceTabTarget,
} from "./state";
-export { buildWorkspaceTabPersistenceKey } from "./state";
+export { buildWorkspaceProjectTabScopeKey, buildWorkspaceTabPersistenceKey } from "./state";
export type { WorkspaceDraftTabSetup, WorkspaceTab, WorkspaceTabTarget } from "./state";
interface WorkspaceTabsState extends WorkspaceTabsCoreState {
diff --git a/packages/app/src/stores/workspace-tabs-store/state.test.ts b/packages/app/src/stores/workspace-tabs-store/state.test.ts
index 68b2ef6f5c..7941a56336 100644
--- a/packages/app/src/stores/workspace-tabs-store/state.test.ts
+++ b/packages/app/src/stores/workspace-tabs-store/state.test.ts
@@ -6,6 +6,7 @@ import {
applyOpenDraftTab,
applyOpenOrFocusTab,
applyRetargetTab,
+ buildWorkspaceProjectTabScopeKey,
buildWorkspaceTabPersistenceKey,
initialWorkspaceTabsCoreState,
type WorkspaceTabsCoreState,
@@ -34,6 +35,15 @@ describe("buildWorkspaceTabPersistenceKey", () => {
}),
).toBe("server-1:setup\\workspace\\");
});
+
+ it("builds a project-scoped tab key without colliding with workspace ids", () => {
+ expect(
+ buildWorkspaceProjectTabScopeKey({
+ serverId: SERVER_ID,
+ projectKey: "project-1",
+ }),
+ ).toBe("server-1:project:project-1");
+ });
});
describe("workspace-tabs-store reducers", () => {
@@ -322,6 +332,32 @@ describe("workspace-tabs-store reducers", () => {
expect(reopened.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(fileResult.tabId);
});
+ it("keeps existing file tab ids when the target carries workspace context", () => {
+ const result = applyOpenOrFocusTab(emptyState(), {
+ serverId: SERVER_ID,
+ workspaceId: WORKSPACE_ID,
+ target: {
+ kind: "file",
+ workspaceId: WORKSPACE_ID,
+ path: "/repo/worktree/src/index.ts",
+ },
+ now: NOW,
+ });
+
+ expect(result.tabId).toBe("file_/repo/worktree/src/index.ts");
+ expect(result.state.uiTabsByWorkspace[WORKSPACE_KEY]).toEqual([
+ {
+ tabId: "file_/repo/worktree/src/index.ts",
+ target: {
+ kind: "file",
+ workspaceId: WORKSPACE_ID,
+ path: "/repo/worktree/src/index.ts",
+ },
+ createdAt: NOW,
+ },
+ ]);
+ });
+
it("builds a deterministic setup tab keyed by workspace id", () => {
const result = applyOpenOrFocusTab(initialWorkspaceTabsCoreState, {
serverId: SERVER_ID,
diff --git a/packages/app/src/stores/workspace-tabs-store/state.ts b/packages/app/src/stores/workspace-tabs-store/state.ts
index 409e171503..81fbc2e6fd 100644
--- a/packages/app/src/stores/workspace-tabs-store/state.ts
+++ b/packages/app/src/stores/workspace-tabs-store/state.ts
@@ -17,11 +17,11 @@ export interface WorkspaceDraftTabSetup {
}
export type WorkspaceTabTarget =
- | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
- | { kind: "agent"; agentId: string }
- | { kind: "terminal"; terminalId: string }
- | { kind: "browser"; browserId: string }
- | WorkspaceFileTabTarget
+ | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup; workspaceId?: string }
+ | { kind: "agent"; agentId: string; workspaceId?: string; allowArchived?: boolean }
+ | { kind: "terminal"; terminalId: string; workspaceId?: string }
+ | { kind: "browser"; browserId: string; workspaceId?: string }
+ | (WorkspaceFileTabTarget & { workspaceId?: string })
| { kind: "setup"; workspaceId: string };
export interface WorkspaceTab {
@@ -62,6 +62,20 @@ export function buildWorkspaceTabPersistenceKey(input: {
return `${serverId}:${workspaceId}`;
}
+export function buildWorkspaceProjectTabScopeKey(input: {
+ serverId: string;
+ projectKey: string | null | undefined;
+}): string | null {
+ const projectKey = trimNonEmpty(input.projectKey);
+ if (!projectKey) {
+ return null;
+ }
+ return buildWorkspaceTabPersistenceKey({
+ serverId: input.serverId,
+ workspaceId: `project:${projectKey}`,
+ });
+}
+
function isPlainRecord(value: unknown): value is Record {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -494,37 +508,94 @@ function extractMigrationRawSources(persistedState: unknown): MigrationRawSource
};
}
+function readRawWorkspaceContext(raw: Record): { workspaceId?: string } {
+ return typeof raw.workspaceId === "string" ? { workspaceId: raw.workspaceId } : {};
+}
+
+function coerceDraftTabTarget(raw: Record): WorkspaceTabTarget | null {
+ if (typeof raw.draftId !== "string") {
+ return null;
+ }
+ const setup = normalizeWorkspaceDraftTabSetup(raw.setup);
+ return normalizeWorkspaceTabTarget({
+ kind: "draft",
+ draftId: raw.draftId,
+ ...readRawWorkspaceContext(raw),
+ ...(setup ? { setup } : {}),
+ });
+}
+
+function coerceAgentTabTarget(raw: Record): WorkspaceTabTarget | null {
+ if (typeof raw.agentId !== "string") {
+ return null;
+ }
+ return normalizeWorkspaceTabTarget({
+ kind: "agent",
+ agentId: raw.agentId,
+ ...readRawWorkspaceContext(raw),
+ ...(raw.allowArchived === true ? { allowArchived: true } : {}),
+ });
+}
+
+function coerceTerminalTabTarget(raw: Record): WorkspaceTabTarget | null {
+ if (typeof raw.terminalId !== "string") {
+ return null;
+ }
+ return normalizeWorkspaceTabTarget({
+ kind: "terminal",
+ terminalId: raw.terminalId,
+ ...readRawWorkspaceContext(raw),
+ });
+}
+
+function coerceBrowserTabTarget(raw: Record): WorkspaceTabTarget | null {
+ if (typeof raw.browserId !== "string") {
+ return null;
+ }
+ return normalizeWorkspaceTabTarget({
+ kind: "browser",
+ browserId: raw.browserId,
+ ...readRawWorkspaceContext(raw),
+ });
+}
+
+function coerceFileTabTarget(raw: Record): WorkspaceTabTarget | null {
+ if (typeof raw.path !== "string") {
+ return null;
+ }
+ return normalizeWorkspaceTabTarget({
+ kind: "file",
+ path: raw.path,
+ ...readRawWorkspaceContext(raw),
+ lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined,
+ lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined,
+ });
+}
+
+function coerceSetupTabTarget(raw: Record): WorkspaceTabTarget | null {
+ if (typeof raw.workspaceId !== "string") {
+ return null;
+ }
+ return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
+}
+
function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTarget | null {
- const kind = typeof raw.kind === "string" ? raw.kind : null;
- if (kind === "draft" && typeof raw.draftId === "string") {
- const setup = normalizeWorkspaceDraftTabSetup(raw.setup);
- return normalizeWorkspaceTabTarget({
- kind: "draft",
- draftId: raw.draftId,
- ...(setup ? { setup } : {}),
- });
- }
- if (kind === "agent" && typeof raw.agentId === "string") {
- return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
- }
- if (kind === "terminal" && typeof raw.terminalId === "string") {
- return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
- }
- if (kind === "browser" && typeof raw.browserId === "string") {
- return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
- }
- if (kind === "file" && typeof raw.path === "string") {
- return normalizeWorkspaceTabTarget({
- kind: "file",
- path: raw.path,
- lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined,
- lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined,
- });
- }
- if (kind === "setup" && typeof raw.workspaceId === "string") {
- return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
+ switch (raw.kind) {
+ case "draft":
+ return coerceDraftTabTarget(raw);
+ case "agent":
+ return coerceAgentTabTarget(raw);
+ case "terminal":
+ return coerceTerminalTabTarget(raw);
+ case "browser":
+ return coerceBrowserTabTarget(raw);
+ case "file":
+ return coerceFileTabTarget(raw);
+ case "setup":
+ return coerceSetupTabTarget(raw);
+ default:
+ return null;
}
- return null;
}
function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null {
diff --git a/packages/app/src/subagents/close-tab-policy.test.ts b/packages/app/src/subagents/close-tab-policy.test.ts
index cdce429b00..cbd08aaa81 100644
--- a/packages/app/src/subagents/close-tab-policy.test.ts
+++ b/packages/app/src/subagents/close-tab-policy.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { resolveCloseAgentTabPolicy } from "./close-tab-policy";
describe("resolveCloseAgentTabPolicy", () => {
- it("archives root agents when their tab closes", () => {
+ it("archives root agent tabs on close", () => {
expect(resolveCloseAgentTabPolicy({ parentAgentId: null })).toEqual({
kind: "archive-on-close",
});
@@ -14,7 +14,7 @@ describe("resolveCloseAgentTabPolicy", () => {
});
});
- it("preserves the existing archive fallback when the agent is missing", () => {
+ it("archives missing agent tabs on close so root fallback stays conservative", () => {
expect(resolveCloseAgentTabPolicy(null)).toEqual({ kind: "archive-on-close" });
expect(resolveCloseAgentTabPolicy(undefined)).toEqual({ kind: "archive-on-close" });
});
diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts
index 3f0c43a0ab..7f04a45299 100644
--- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts
+++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts
@@ -83,6 +83,33 @@ describe("resolveNavigateToAgent", () => {
]);
});
+ it("marks explicitly opened archived agents so reconciliation can keep the viewer tab", () => {
+ const { deps, tabNavigations } = createFakeNavigators({
+ workspaces: [createWorkspace()],
+ agentCwd: "/repo/worktree",
+ });
+
+ resolveNavigateToAgent(
+ {
+ serverId: SERVER_ID,
+ agentId: AGENT_ID,
+ pin: true,
+ allowArchived: true,
+ },
+ deps,
+ );
+
+ expect(tabNavigations).toEqual([
+ {
+ serverId: SERVER_ID,
+ workspaceId: WORKSPACE_ID,
+ target: { kind: "agent", agentId: AGENT_ID, allowArchived: true },
+ currentPathname: undefined,
+ pin: true,
+ },
+ ]);
+ });
+
it("falls back to the host agent route when the workspace is unknown", () => {
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
workspaces: [],
diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts
index a9c2869244..a2f22e03a9 100644
--- a/packages/app/src/utils/navigate-to-agent/resolve.ts
+++ b/packages/app/src/utils/navigate-to-agent/resolve.ts
@@ -8,6 +8,7 @@ export interface NavigateToAgentInput {
agentId: string;
currentPathname?: string | null;
pin?: boolean;
+ allowArchived?: boolean;
}
export interface AgentNavTarget {
@@ -43,7 +44,11 @@ export function resolveNavigateToAgent(
return deps.navigateToPreparedWorkspaceTab({
serverId: input.serverId,
workspaceId,
- target: { kind: "agent", agentId: input.agentId },
+ target: {
+ kind: "agent",
+ agentId: input.agentId,
+ ...(input.allowArchived === true ? { allowArchived: true } : {}),
+ },
currentPathname: input.currentPathname,
pin: input.pin,
});
diff --git a/packages/app/src/utils/prepare-workspace-tab.ts b/packages/app/src/utils/prepare-workspace-tab.ts
index ee116ff3be..ac54dafc44 100644
--- a/packages/app/src/utils/prepare-workspace-tab.ts
+++ b/packages/app/src/utils/prepare-workspace-tab.ts
@@ -8,6 +8,7 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
export interface PrepareWorkspaceTabInput {
serverId: string;
workspaceId: string;
+ tabScopeKey?: string | null;
target: WorkspaceTabTarget;
pin?: boolean;
}
@@ -29,23 +30,39 @@ export interface NavigateToPreparedWorkspaceTabDeps extends PrepareWorkspaceTabD
) => void;
}
-function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
- if (target.kind !== "draft" || target.draftId.trim() !== "new") {
+function withWorkspaceContext(target: WorkspaceTabTarget, workspaceId: string): WorkspaceTabTarget {
+ if (target.kind === "setup") {
return target;
}
- return { kind: "draft", draftId: generateDraftId() };
+ if ("workspaceId" in target && target.workspaceId?.trim()) {
+ return target;
+ }
+ return {
+ ...target,
+ workspaceId,
+ } as WorkspaceTabTarget;
+}
+
+function getPreparedTarget(target: WorkspaceTabTarget, workspaceId: string): WorkspaceTabTarget {
+ const contextualTarget = withWorkspaceContext(target, workspaceId);
+ if (contextualTarget.kind !== "draft" || contextualTarget.draftId.trim() !== "new") {
+ return contextualTarget;
+ }
+ return { ...contextualTarget, draftId: generateDraftId() };
}
export function prepareWorkspaceTab(
input: PrepareWorkspaceTabInput,
deps: PrepareWorkspaceTabDeps,
): string {
- const target = getPreparedTarget(input.target);
+ const target = getPreparedTarget(input.target, input.workspaceId);
const key =
+ input.tabScopeKey ??
buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
- }) ?? "";
+ }) ??
+ "";
deps.openTabFocused(key, target);
diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts
index ce1768daa2..9f410890e1 100644
--- a/packages/app/src/utils/sidebar-project-row-model.test.ts
+++ b/packages/app/src/utils/sidebar-project-row-model.test.ts
@@ -4,6 +4,7 @@ import {
isSidebarProjectFlattened,
} from "./sidebar-project-row-model";
import type {
+ SidebarAgentEntry,
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
@@ -18,6 +19,7 @@ function workspace(overrides: Partial = {}): SidebarWorks
projectKind: "git",
workspaceKind: "checkout",
name: "paseo",
+ branchName: null,
statusBucket: "done",
diffStat: null,
prHint: null,
@@ -40,6 +42,28 @@ function project(overrides: Partial = {}): SidebarProjectEn
iconWorkingDir: "/repo",
canCreateWorktree: overrides.canCreateWorktree ?? projectKind === "git",
workspaces: [workspace()],
+ agents: [],
+ ...overrides,
+ };
+}
+
+function agent(overrides: Partial = {}): SidebarAgentEntry {
+ return {
+ rowKey: "srv:agent:agent-1",
+ serverId: "srv",
+ agentId: "agent-1",
+ projectKey: "project-1",
+ workspaceId: "ws-root",
+ workspaceDirectory: "/repo",
+ workspaceName: "paseo",
+ workspaceKind: "checkout",
+ title: "Trial sidebar",
+ statusBucket: "running",
+ provider: "codex",
+ branchName: "main",
+ lastActivityAt: new Date("2026-06-02T12:00:00.000Z"),
+ pendingPermissionCount: 0,
+ requiresAttention: false,
...overrides,
};
}
@@ -112,7 +136,7 @@ describe("buildSidebarProjectRowModel", () => {
});
});
- it("keeps multi-workspace git projects as expandable sections with a new worktree action", () => {
+ it("keeps multi-workspace git projects expandable in workspace-first mode", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: "git",
@@ -130,6 +154,42 @@ describe("buildSidebarProjectRowModel", () => {
trailingAction: "new_worktree",
});
});
+
+ it("uses thread rows, not workspace count, for thread-first expandability", () => {
+ const result = buildSidebarProjectRowModel({
+ project: project({
+ projectKind: "git",
+ workspaces: [
+ workspace({ workspaceId: "ws-main", workspaceKind: "checkout" }),
+ workspace({ workspaceId: "ws-feature", workspaceKind: "worktree" }),
+ ],
+ }),
+ collapsed: true,
+ organizationMode: "thread-first",
+ });
+
+ expect(result).toEqual({
+ kind: "project_section",
+ chevron: null,
+ trailingAction: "new_worktree",
+ });
+ });
+
+ it("makes projects with thread rows expandable in thread-first mode", () => {
+ const result = buildSidebarProjectRowModel({
+ project: project({
+ agents: [agent()],
+ }),
+ collapsed: true,
+ organizationMode: "thread-first",
+ });
+
+ expect(result).toEqual({
+ kind: "project_section",
+ chevron: "expand",
+ trailingAction: "new_worktree",
+ });
+ });
});
describe("isSidebarProjectFlattened", () => {
@@ -154,4 +214,29 @@ describe("isSidebarProjectFlattened", () => {
),
).toBe(false);
});
+
+ it("ignores hidden agent rows when flattening workspace-first projects", () => {
+ expect(
+ isSidebarProjectFlattened(
+ project({
+ projectKind: "directory",
+ workspaces: [workspace()],
+ agents: [agent()],
+ }),
+ ),
+ ).toBe(true);
+ });
+
+ it("returns false for thread-first projects that have agent rows", () => {
+ expect(
+ isSidebarProjectFlattened(
+ project({
+ projectKind: "directory",
+ workspaces: [workspace()],
+ agents: [agent()],
+ }),
+ "thread-first",
+ ),
+ ).toBe(false);
+ });
});
diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts
index 2a4ffc3d03..92e08d40fb 100644
--- a/packages/app/src/utils/sidebar-project-row-model.ts
+++ b/packages/app/src/utils/sidebar-project-row-model.ts
@@ -2,6 +2,7 @@ import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
+import type { WorkspaceOrganizationMode } from "@/stores/workspace-organization-store";
export interface SidebarProjectWorkspaceLinkRowModel {
kind: "workspace_link";
@@ -20,15 +21,23 @@ export type SidebarProjectRowModel =
| SidebarProjectWorkspaceLinkRowModel
| SidebarProjectSectionRowModel;
-export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean {
+export function isSidebarProjectFlattened(
+ project: SidebarProjectEntry,
+ organizationMode: WorkspaceOrganizationMode = "workspace-first",
+): boolean {
+ if (organizationMode === "thread-first" && project.agents.length > 0) {
+ return false;
+ }
return project.workspaces.length === 1 && project.projectKind !== "git";
}
export function buildSidebarProjectRowModel(input: {
project: SidebarProjectEntry;
collapsed: boolean;
+ organizationMode?: WorkspaceOrganizationMode;
}): SidebarProjectRowModel {
- const flattenedWorkspace = isSidebarProjectFlattened(input.project)
+ const organizationMode = input.organizationMode ?? "workspace-first";
+ const flattenedWorkspace = isSidebarProjectFlattened(input.project, organizationMode)
? (input.project.workspaces[0] ?? null)
: null;
@@ -41,7 +50,10 @@ export function buildSidebarProjectRowModel(input: {
};
}
- const collapsible = input.project.projectKind === "git" || input.project.workspaces.length > 1;
+ const collapsible =
+ organizationMode === "thread-first"
+ ? input.project.agents.length > 0
+ : input.project.projectKind === "git" || input.project.workspaces.length > 1;
let chevron: "expand" | "collapse" | null;
if (!collapsible) chevron = null;
diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts
index bc53acbaa9..51e9ca819c 100644
--- a/packages/app/src/utils/sidebar-shortcuts.test.ts
+++ b/packages/app/src/utils/sidebar-shortcuts.test.ts
@@ -28,6 +28,7 @@ function workspace(input: {
projectKind: "git",
workspaceKind: "checkout",
name: input.name,
+ branchName: null,
statusBucket: input.statusBucket ?? "done",
archivingAt: null,
statusEnteredAt: input.statusEnteredAt ?? null,
@@ -48,6 +49,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
canCreateWorktree: true,
workspaces,
+ agents: [],
};
}
@@ -138,6 +140,28 @@ describe("buildSidebarShortcutModel", () => {
expect(model.shortcutTargets).toEqual([]);
});
+
+ it("does not create hidden workspace shortcuts in thread-first organization", () => {
+ const projects = [
+ project("p1", [
+ workspace({
+ serverId: "s1",
+ workspaceId: "ws-main",
+ workspaceDirectory: "/repo/main",
+ name: "main",
+ }),
+ ]),
+ ];
+
+ const model = buildSidebarShortcutModel({
+ projects,
+ collapsedProjectKeys: new Set(),
+ organizationMode: "thread-first",
+ });
+
+ expect(model.shortcutTargets).toEqual([]);
+ expect(model.shortcutIndexByWorkspaceKey.size).toBe(0);
+ });
});
describe("buildStatusSidebarShortcutModel", () => {
diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts
index 2c75c8905f..a9f4808277 100644
--- a/packages/app/src/utils/sidebar-shortcuts.ts
+++ b/packages/app/src/utils/sidebar-shortcuts.ts
@@ -3,6 +3,7 @@ import type {
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
+import type { WorkspaceOrganizationMode } from "@/stores/workspace-organization-store";
import { isSidebarProjectFlattened } from "./sidebar-project-row-model";
export interface SidebarShortcutWorkspaceTarget {
@@ -25,12 +26,17 @@ function createShortcutTarget(workspace: SidebarWorkspaceEntry): SidebarShortcut
export function buildSidebarShortcutModel(input: {
projects: SidebarProjectEntry[];
collapsedProjectKeys: ReadonlySet;
+ organizationMode?: WorkspaceOrganizationMode;
shortcutLimit?: number;
}): SidebarShortcutModel {
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [];
const shortcutIndexByWorkspaceKey = new Map();
+ if (input.organizationMode === "thread-first") {
+ return { shortcutTargets, shortcutIndexByWorkspaceKey };
+ }
+
for (const project of input.projects) {
if (!isSidebarProjectFlattened(project) && input.collapsedProjectKeys.has(project.projectKey)) {
continue;
diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts
index af08350ab6..7aed6122d5 100644
--- a/packages/app/src/utils/workspace-navigation.test.ts
+++ b/packages/app/src/utils/workspace-navigation.test.ts
@@ -67,11 +67,39 @@ describe("prepareWorkspaceTab", () => {
expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU");
expect(layout.openedTabs).toEqual([
- { key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } },
+ {
+ key: "server-1:/repo/worktree",
+ target: { kind: "agent", agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
+ },
]);
expect(layout.pinnedAgents).toEqual([]);
});
+ it("opens and pins against an explicit tab scope key", () => {
+ const layout = createFakeLayout();
+ const tabScopeKey = "server-1:project:project-1";
+
+ const route = prepareWorkspaceTab(
+ {
+ serverId: SERVER_ID,
+ workspaceId: WORKSPACE_ID,
+ tabScopeKey,
+ target: { kind: "agent", agentId: AGENT_ID },
+ pin: true,
+ },
+ layout,
+ );
+
+ expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU");
+ expect(layout.openedTabs).toEqual([
+ {
+ key: tabScopeKey,
+ target: { kind: "agent", agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
+ },
+ ]);
+ expect(layout.pinnedAgents).toEqual([{ key: tabScopeKey, agentId: AGENT_ID }]);
+ });
+
it("prepares a tab and navigates through the workspace navigation helper", () => {
const layout = createFakeLayout();
const navigator = createFakeNavigator();
@@ -87,7 +115,10 @@ describe("prepareWorkspaceTab", () => {
expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU");
expect(layout.openedTabs).toEqual([
- { key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } },
+ {
+ key: "server-1:/repo/worktree",
+ target: { kind: "agent", agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
+ },
]);
expect(navigator.navigations).toEqual([
{ serverId: SERVER_ID, workspaceId: WORKSPACE_ID, currentPathname: undefined },
diff --git a/packages/app/src/utils/workspace-navigation.ts b/packages/app/src/utils/workspace-navigation.ts
index 514929c6d4..41aa0ce76d 100644
--- a/packages/app/src/utils/workspace-navigation.ts
+++ b/packages/app/src/utils/workspace-navigation.ts
@@ -1,5 +1,15 @@
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
-import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
+import { useSessionStore } from "@/stores/session-store";
+import {
+ getWorkspaceOrganizationPolicy,
+ useWorkspaceOrganizationStore,
+} from "@/stores/workspace-organization-store";
+import {
+ buildWorkspaceProjectTabScopeKey,
+ buildWorkspaceTabPersistenceKey,
+ useWorkspaceLayoutStore,
+} from "@/stores/workspace-layout-store";
+import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
import {
prepareWorkspaceTab as prepareWorkspaceTabPure,
navigateToPreparedWorkspaceTab as navigateToPreparedWorkspaceTabPure,
@@ -20,13 +30,56 @@ function layoutStoreDeps() {
};
}
+function resolvePreparedTabScopeKey(input: {
+ serverId: string;
+ workspaceId: string;
+}): string | null {
+ const policy = getWorkspaceOrganizationPolicy(useWorkspaceOrganizationStore.getState().mode);
+ if (policy.tabScope !== "project") {
+ return buildWorkspaceTabPersistenceKey({
+ serverId: input.serverId,
+ workspaceId: input.workspaceId,
+ });
+ }
+
+ const session = useSessionStore.getState().sessions[input.serverId];
+ const workspaceKey = resolveWorkspaceMapKeyByIdentity({
+ workspaces: session?.workspaces,
+ workspaceId: input.workspaceId,
+ });
+ const workspace = workspaceKey ? (session?.workspaces.get(workspaceKey) ?? null) : null;
+ const projectKey = workspace?.project?.projectKey ?? workspace?.projectId ?? null;
+ return (
+ buildWorkspaceProjectTabScopeKey({
+ serverId: input.serverId,
+ projectKey,
+ }) ??
+ buildWorkspaceTabPersistenceKey({
+ serverId: input.serverId,
+ workspaceId: input.workspaceId,
+ })
+ );
+}
+
export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string {
- return prepareWorkspaceTabPure(input, layoutStoreDeps());
+ return prepareWorkspaceTabPure(
+ {
+ ...input,
+ tabScopeKey: input.tabScopeKey ?? resolvePreparedTabScopeKey(input),
+ },
+ layoutStoreDeps(),
+ );
}
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
- return navigateToPreparedWorkspaceTabPure(input, {
- ...layoutStoreDeps(),
- navigateToWorkspace,
- });
+ return navigateToPreparedWorkspaceTabPure(
+ {
+ ...input,
+ tabScopeKey: input.tabScopeKey ?? resolvePreparedTabScopeKey(input),
+ },
+ {
+ ...layoutStoreDeps(),
+ navigateToWorkspace,
+ },
+ );
}
diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts
index 3e4c9bb369..418e72c3d0 100644
--- a/packages/app/src/workspace-tabs/agent-visibility.test.ts
+++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
-import type { Agent } from "@/stores/session-store";
+import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import {
buildWorkspaceTabSnapshot,
+ deriveProjectAgentVisibility,
deriveWorkspaceAgentVisibility,
shouldPruneWorkspaceAgentTab,
workspaceAgentVisibilityEqual,
@@ -14,6 +15,7 @@ function makeAgent(input: {
archivedAt?: Date | null;
createdAt?: Date;
lastActivityAt?: Date;
+ projectPlacement?: Agent["projectPlacement"] | null;
}): Agent {
const createdAt = input.createdAt ?? new Date("2026-03-04T00:00:00.000Z");
const lastActivityAt = input.lastActivityAt ?? createdAt;
@@ -52,11 +54,49 @@ function makeAgent(input: {
attentionReason: null,
attentionTimestamp: null,
archivedAt: input.archivedAt ?? null,
+ projectPlacement: input.projectPlacement ?? null,
+ };
+}
+
+function makeWorkspace(input: {
+ id: string;
+ projectKey: string;
+ workspaceDirectory: string;
+ currentBranch?: string | null;
+}): WorkspaceDescriptor {
+ return {
+ id: input.id,
+ projectId: input.projectKey,
+ projectDisplayName: input.projectKey,
+ projectCustomName: null,
+ projectRootPath: input.workspaceDirectory,
+ workspaceDirectory: input.workspaceDirectory,
+ projectKind: "git",
+ workspaceKind: "checkout",
+ name: input.currentBranch ?? input.id,
+ status: "done",
+ statusEnteredAt: null,
+ archivingAt: null,
+ diffStat: null,
+ scripts: [],
+ project: {
+ projectKey: input.projectKey,
+ projectName: input.projectKey,
+ checkout: {
+ cwd: input.workspaceDirectory,
+ isGit: true,
+ currentBranch: input.currentBranch ?? null,
+ remoteUrl: null,
+ worktreeRoot: input.workspaceDirectory,
+ isPaseoOwnedWorktree: false,
+ mainRepoRoot: null,
+ },
+ },
};
}
describe("workspace agent visibility", () => {
- it("keeps subagents active and known while excluding them from auto-open", () => {
+ it("keeps agents active and known without auto-opening agent tabs", () => {
const workspaceDirectory = "/repo/worktree";
const parent = makeAgent({
id: "parent-agent",
@@ -77,11 +117,10 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
- expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
});
- it("keeps archived subagents known but excludes them from active and auto-open", () => {
+ it("keeps archived subagents known but excludes them from active", () => {
const workspaceDirectory = "/repo/worktree";
const archivedChild = makeAgent({
id: "archived-child",
@@ -96,11 +135,10 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set());
- expect(result.autoOpenAgentIds).toEqual(new Set());
expect(result.knownAgentIds).toEqual(new Set(["archived-child"]));
});
- it("excludes a child from auto-open even when its snapshot arrives before the parent", () => {
+ it("keeps a child visible even when its snapshot arrives before the parent", () => {
const workspaceDirectory = "/repo/worktree";
const child = makeAgent({
id: "child-agent",
@@ -121,7 +159,6 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set(["child-agent", "parent-agent"]));
- expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["child-agent", "parent-agent"]));
});
@@ -155,7 +192,6 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
- expect(result.autoOpenAgentIds).toEqual(new Set(["visible-agent"]));
expect(result.knownAgentIds.has("visible-agent")).toBe(true);
expect(result.knownAgentIds.has("archived-agent")).toBe(true);
expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false);
@@ -241,7 +277,6 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set(["slash-agent"]));
- expect(result.autoOpenAgentIds).toEqual(new Set(["slash-agent"]));
expect(result.knownAgentIds.has("slash-agent")).toBe(true);
});
@@ -262,20 +297,93 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set(["recent-agent"]));
- expect(result.autoOpenAgentIds).toEqual(new Set(["recent-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["recent-agent"]));
});
+ it("derives project visibility across branch workspaces in the same repo", () => {
+ const workspaces = new Map([
+ [
+ "ws-main",
+ makeWorkspace({
+ id: "ws-main",
+ projectKey: "project-1",
+ workspaceDirectory: "/repo",
+ currentBranch: "main",
+ }),
+ ],
+ [
+ "ws-feature",
+ makeWorkspace({
+ id: "ws-feature",
+ projectKey: "project-1",
+ workspaceDirectory: "/repo-feature",
+ currentBranch: "feature",
+ }),
+ ],
+ [
+ "ws-other",
+ makeWorkspace({
+ id: "ws-other",
+ projectKey: "project-2",
+ workspaceDirectory: "/other-repo",
+ currentBranch: "main",
+ }),
+ ],
+ ]);
+ const placedAgent = makeAgent({
+ id: "placed-agent",
+ cwd: "/tmp/outside-visible-workspaces",
+ projectPlacement: {
+ projectKey: "project-1",
+ projectName: "Project 1",
+ checkout: {
+ cwd: "/tmp/outside-visible-workspaces",
+ isGit: true,
+ currentBranch: "detached-task",
+ remoteUrl: null,
+ worktreeRoot: "/tmp/outside-visible-workspaces",
+ isPaseoOwnedWorktree: false,
+ mainRepoRoot: null,
+ },
+ },
+ });
+ const archivedFeatureAgent = makeAgent({
+ id: "archived-feature-agent",
+ cwd: "/repo-feature",
+ archivedAt: new Date("2026-03-04T00:02:00.000Z"),
+ });
+ const sessionAgents = new Map([
+ ["main-agent", makeAgent({ id: "main-agent", cwd: "/repo" })],
+ ["feature-agent", makeAgent({ id: "feature-agent", cwd: "/repo-feature" })],
+ [placedAgent.id, placedAgent],
+ [archivedFeatureAgent.id, archivedFeatureAgent],
+ ["other-agent", makeAgent({ id: "other-agent", cwd: "/other-repo" })],
+ ]);
+
+ const result = deriveProjectAgentVisibility({
+ sessionAgents,
+ workspaces,
+ projectKey: "project-1",
+ fallbackWorkspaceDirectory: "/repo",
+ });
+
+ expect(result.activeAgentIds).toEqual(new Set(["main-agent", "feature-agent", "placed-agent"]));
+ expect(result.knownAgentIds).toEqual(
+ new Set(["main-agent", "feature-agent", "placed-agent", "archived-feature-agent"]),
+ );
+ });
+
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
const agentVisibility = {
activeAgentIds: new Set(["active-agent"]),
- autoOpenAgentIds: new Set(["root-agent"]),
knownAgentIds: new Set(["active-agent", "archived-agent"]),
};
expect(
buildWorkspaceTabSnapshot({
+ workspaceId: "workspace-a",
agentVisibility,
+ autoOpenAgentIds: ["active-agent"],
agentsHydrated: true,
terminalsHydrated: true,
knownTerminalIds: ["terminal-1", "script-terminal"],
@@ -283,10 +391,11 @@ describe("workspace agent visibility", () => {
hasActivePendingDraftCreate: false,
}),
).toEqual({
+ workspaceId: "workspace-a",
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: agentVisibility.activeAgentIds,
- autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
+ autoOpenAgentIds: ["active-agent"],
knownAgentIds: agentVisibility.knownAgentIds,
knownTerminalIds: ["terminal-1", "script-terminal"],
standaloneTerminalIds: ["terminal-1"],
@@ -298,12 +407,10 @@ describe("workspace agent visibility", () => {
it("returns true for identical sets", () => {
const a = {
activeAgentIds: new Set(["a", "b"]),
- autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a", "b", "c"]),
};
const b = {
activeAgentIds: new Set(["a", "b"]),
- autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a", "b", "c"]),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
@@ -312,40 +419,22 @@ describe("workspace agent visibility", () => {
it("returns false when activeAgentIds differ", () => {
const a = {
activeAgentIds: new Set(["a"]),
- autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a"]),
};
const b = {
activeAgentIds: new Set(["b"]),
- autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a"]),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
});
- it("returns false when autoOpenAgentIds differ", () => {
- const a = {
- activeAgentIds: new Set(["a", "b"]),
- autoOpenAgentIds: new Set(["a"]),
- knownAgentIds: new Set(["a", "b"]),
- };
- const b = {
- activeAgentIds: new Set(["a", "b"]),
- autoOpenAgentIds: new Set(["b"]),
- knownAgentIds: new Set(["a", "b"]),
- };
- expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
- });
-
it("returns false when knownAgentIds differ", () => {
const a = {
activeAgentIds: new Set(["a"]),
- autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a"]),
};
const b = {
activeAgentIds: new Set(["a"]),
- autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a", "b"]),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
@@ -354,12 +443,10 @@ describe("workspace agent visibility", () => {
it("returns true for empty sets", () => {
const a = {
activeAgentIds: new Set(),
- autoOpenAgentIds: new Set(),
knownAgentIds: new Set(),
};
const b = {
activeAgentIds: new Set(),
- autoOpenAgentIds: new Set(),
knownAgentIds: new Set(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts
index 45235f8578..fe98f06a28 100644
--- a/packages/app/src/workspace-tabs/agent-visibility.ts
+++ b/packages/app/src/workspace-tabs/agent-visibility.ts
@@ -1,6 +1,5 @@
-import type { Agent } from "@/stores/session-store";
+import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
-import { shouldAutoOpenAgentTab } from "@/subagents/policies";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
function normalizeWorkspaceId(value: string | null | undefined): string {
@@ -9,7 +8,6 @@ function normalizeWorkspaceId(value: string | null | undefined): string {
export interface WorkspaceAgentVisibility {
activeAgentIds: Set;
- autoOpenAgentIds: Set;
knownAgentIds: Set;
}
@@ -23,13 +21,11 @@ export function deriveWorkspaceAgentVisibility(input: {
if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) {
return {
activeAgentIds: new Set(),
- autoOpenAgentIds: new Set(),
knownAgentIds: new Set(),
};
}
const activeAgentIds = new Set();
- const autoOpenAgentIds = new Set();
const knownAgentIds = new Set();
for (const agent of sessionAgents?.values() ?? []) {
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
@@ -38,9 +34,6 @@ export function deriveWorkspaceAgentVisibility(input: {
knownAgentIds.add(agent.id);
if (!agent.archivedAt) {
activeAgentIds.add(agent.id);
- if (shouldAutoOpenAgentTab(agent)) {
- autoOpenAgentIds.add(agent.id);
- }
}
}
for (const agent of agentDetails?.values() ?? []) {
@@ -50,11 +43,93 @@ export function deriveWorkspaceAgentVisibility(input: {
knownAgentIds.add(agent.id);
}
- return { activeAgentIds, autoOpenAgentIds, knownAgentIds };
+ return { activeAgentIds, knownAgentIds };
+}
+
+function buildNormalizedProjectWorkspaceDirectories(
+ workspaces: Map | undefined,
+ projectKey: string | null,
+): Set {
+ const directories = new Set();
+ if (!workspaces || !projectKey) {
+ return directories;
+ }
+ for (const workspace of workspaces.values()) {
+ const workspaceProjectKey = workspace.project?.projectKey ?? workspace.projectId;
+ if (workspaceProjectKey !== projectKey) {
+ continue;
+ }
+ const normalizedDirectory = normalizeWorkspaceId(workspace.workspaceDirectory);
+ if (normalizedDirectory) {
+ directories.add(normalizedDirectory);
+ }
+ }
+ return directories;
+}
+
+function agentBelongsToProject(input: {
+ agent: Agent;
+ projectKey: string | null;
+ workspaceDirectories: Set;
+}): boolean {
+ const normalizedCwd = normalizeWorkspaceId(input.agent.cwd);
+ if (!normalizedCwd) {
+ return false;
+ }
+ if (input.projectKey && input.agent.projectPlacement?.projectKey === input.projectKey) {
+ return true;
+ }
+ return input.workspaceDirectories.has(normalizedCwd);
+}
+
+export function deriveProjectAgentVisibility(input: {
+ sessionAgents: Map | undefined;
+ agentDetails?: Map | undefined;
+ workspaces: Map | undefined;
+ projectKey: string | null | undefined;
+ fallbackWorkspaceDirectory: string | null | undefined;
+}): WorkspaceAgentVisibility {
+ const projectKey = input.projectKey?.trim() || null;
+ const workspaceDirectories = buildNormalizedProjectWorkspaceDirectories(
+ input.workspaces,
+ projectKey,
+ );
+ const fallbackWorkspaceDirectory = normalizeWorkspaceId(input.fallbackWorkspaceDirectory);
+ if (workspaceDirectories.size === 0 && fallbackWorkspaceDirectory) {
+ workspaceDirectories.add(fallbackWorkspaceDirectory);
+ }
+ if ((!input.sessionAgents && !input.agentDetails) || workspaceDirectories.size === 0) {
+ return {
+ activeAgentIds: new Set(),
+ knownAgentIds: new Set(),
+ };
+ }
+
+ const activeAgentIds = new Set();
+ const knownAgentIds = new Set();
+ for (const agent of input.sessionAgents?.values() ?? []) {
+ if (!agentBelongsToProject({ agent, projectKey, workspaceDirectories })) {
+ continue;
+ }
+ knownAgentIds.add(agent.id);
+ if (!agent.archivedAt) {
+ activeAgentIds.add(agent.id);
+ }
+ }
+ for (const agent of input.agentDetails?.values() ?? []) {
+ if (!agentBelongsToProject({ agent, projectKey, workspaceDirectories })) {
+ continue;
+ }
+ knownAgentIds.add(agent.id);
+ }
+
+ return { activeAgentIds, knownAgentIds };
}
export function buildWorkspaceTabSnapshot(input: {
+ workspaceId?: string | null;
agentVisibility: WorkspaceAgentVisibility;
+ autoOpenAgentIds?: Iterable;
agentsHydrated: boolean;
terminalsHydrated: boolean;
knownTerminalIds: Iterable;
@@ -62,10 +137,11 @@ export function buildWorkspaceTabSnapshot(input: {
hasActivePendingDraftCreate: boolean;
}): WorkspaceTabSnapshot {
return {
+ workspaceId: input.workspaceId,
agentsHydrated: input.agentsHydrated,
terminalsHydrated: input.terminalsHydrated,
activeAgentIds: input.agentVisibility.activeAgentIds,
- autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
+ autoOpenAgentIds: input.autoOpenAgentIds,
knownAgentIds: input.agentVisibility.knownAgentIds,
knownTerminalIds: input.knownTerminalIds,
standaloneTerminalIds: input.standaloneTerminalIds,
@@ -78,9 +154,7 @@ export function workspaceAgentVisibilityEqual(
b: WorkspaceAgentVisibility,
): boolean {
return (
- setsEqual(a.activeAgentIds, b.activeAgentIds) &&
- setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) &&
- setsEqual(a.knownAgentIds, b.knownAgentIds)
+ setsEqual(a.activeAgentIds, b.activeAgentIds) && setsEqual(a.knownAgentIds, b.knownAgentIds)
);
}
diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts
index 35a2693e87..282d6a8061 100644
--- a/packages/app/src/workspace-tabs/identity.ts
+++ b/packages/app/src/workspace-tabs/identity.ts
@@ -15,19 +15,32 @@ export function normalizeWorkspaceTabTarget(
return null;
}
const setup = normalizeWorkspaceDraftTabSetup(value.setup);
- return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
+ const workspaceId = normalizeTargetWorkspaceId(value.workspaceId);
+ return {
+ kind: "draft",
+ draftId,
+ ...(workspaceId ? { workspaceId } : {}),
+ ...(setup ? { setup } : {}),
+ };
}
if (value.kind === "agent") {
- const agentId = trimNonEmpty(value.agentId);
- return agentId ? { kind: "agent", agentId } : null;
+ return normalizeAgentTabTarget(value);
}
if (value.kind === "terminal") {
const terminalId = trimNonEmpty(value.terminalId);
- return terminalId ? { kind: "terminal", terminalId } : null;
+ if (!terminalId) {
+ return null;
+ }
+ const workspaceId = normalizeTargetWorkspaceId(value.workspaceId);
+ return { kind: "terminal", terminalId, ...(workspaceId ? { workspaceId } : {}) };
}
if (value.kind === "browser") {
const browserId = trimNonEmpty(value.browserId);
- return browserId ? { kind: "browser", browserId } : null;
+ if (!browserId) {
+ return null;
+ }
+ const workspaceId = normalizeTargetWorkspaceId(value.workspaceId);
+ return { kind: "browser", browserId, ...(workspaceId ? { workspaceId } : {}) };
}
if (value.kind === "file") {
return normalizeFileTabTarget(value);
@@ -74,7 +87,9 @@ export function workspaceTabTargetsEqual(
return left.draftId === right.draftId && workspaceDraftTabSetupsEqual(left.setup, right.setup);
}
if (left.kind === "agent" && right.kind === "agent") {
- return left.agentId === right.agentId;
+ const leftAllowsArchived = left.allowArchived === true;
+ const rightAllowsArchived = right.allowArchived === true;
+ return left.agentId === right.agentId && leftAllowsArchived === rightAllowsArchived;
}
if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId;
@@ -83,7 +98,7 @@ export function workspaceTabTargetsEqual(
return left.browserId === right.browserId;
}
if (left.kind === "file" && right.kind === "file") {
- return workspaceFileLocationsEqual(left, right);
+ return workspaceFileLocationsEqual(left, right) && targetWorkspaceIdsEqual(left, right);
}
if (left.kind === "setup" && right.kind === "setup") {
return left.workspaceId === right.workspaceId;
@@ -108,6 +123,22 @@ function workspaceDraftTabSetupsEqual(
);
}
+function normalizeAgentTabTarget(
+ value: Extract,
+): WorkspaceTabTarget | null {
+ const agentId = trimNonEmpty(value.agentId);
+ if (!agentId) {
+ return null;
+ }
+ const workspaceId = normalizeTargetWorkspaceId(value.workspaceId);
+ return {
+ kind: "agent",
+ agentId,
+ ...(workspaceId ? { workspaceId } : {}),
+ ...(value.allowArchived === true ? { allowArchived: true } : {}),
+ };
+}
+
function recordsShallowEqual(
left: Record,
right: Record,
@@ -140,7 +171,10 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
if (target.kind === "setup") {
return `setup_${target.workspaceId}`;
}
- return `file_${target.path}`;
+ if (target.kind === "file") {
+ return `file_${target.path}`;
+ }
+ return "tab_unknown";
}
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -155,7 +189,24 @@ function normalizeFileTabTarget(
value: Extract,
): WorkspaceTabTarget | null {
const location = normalizeWorkspaceFileLocation(value);
- return location ? { kind: "file", ...location } : null;
+ if (!location) {
+ return null;
+ }
+ const workspaceId = normalizeTargetWorkspaceId(value.workspaceId);
+ return { kind: "file", ...(workspaceId ? { workspaceId } : {}), ...location };
+}
+
+function normalizeTargetWorkspaceId(value: string | null | undefined): string | null {
+ return trimNonEmpty(value);
+}
+
+function targetWorkspaceIdsEqual(
+ left: { workspaceId?: string },
+ right: { workspaceId?: string },
+): boolean {
+ return (
+ normalizeTargetWorkspaceId(left.workspaceId) === normalizeTargetWorkspaceId(right.workspaceId)
+ );
}
function trimOptionalString(value: string | null | undefined): string | null {