feat: add explicit drag handles to sidebar lists and refactor workspace tab model

This commit is contained in:
Mohamed Boudra
2026-03-04 11:57:22 +07:00
parent 952ff0dd72
commit b5b4e60916
11 changed files with 984 additions and 797 deletions
@@ -193,7 +193,11 @@ export function WorkspaceDesktopTabsRow({
onPress={() => {
onNavigateTab(tab.tabId);
}}
accessibilityLabel={tab.label}
accessibilityLabel={
tab.kind === "agent" && tab.titleState === "loading"
? "Loading agent title"
: tab.label
}
>
<View
{...(dragHandleProps?.attributes as any)}
@@ -203,6 +207,14 @@ export function WorkspaceDesktopTabsRow({
>
<View style={styles.tabIcon}>{icon}</View>
{layout.showLabels ? (
tab.kind === "agent" && tab.titleState === "loading" ? (
<View
style={[
styles.tabLabelSkeleton,
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
]}
/>
) : (
<Text
style={[
styles.tabLabel,
@@ -213,6 +225,7 @@ export function WorkspaceDesktopTabsRow({
>
{tab.label}
</Text>
)
) : null}
</View>
@@ -436,6 +449,16 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
tabLabelSkeleton: {
width: 96,
height: 10,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
opacity: 0.9,
},
tabLabelSkeletonWithCloseButton: {
width: 80,
},
tabLabelWithCloseButton: {
paddingRight: 0,
},
@@ -44,13 +44,10 @@ import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
import {
useSessionStore,
type Agent,
} from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceTabsStore,
type WorkspaceTabTarget,
type WorkspaceTab,
} from "@/stores/workspace-tabs-store";
import {
buildHostWorkspaceRoute,
@@ -79,17 +76,17 @@ import {
shouldRenderMissingWorkspaceDescriptor,
} from "@/screens/workspace/workspace-header-source";
import {
canOpenAgentTabFromRoute,
deriveWorkspaceAgentVisibility,
} from "@/screens/workspace/workspace-agent-visibility";
import {
deriveWorkspaceTabModel,
} from "@/screens/workspace/workspace-tab-model";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
const EMPTY_OPEN_FILE_PATHS: string[] = [];
const EMPTY_WORKSPACE_TABS: WorkspaceTab[] = [];
type TabAvailability = "available" | "invalid" | "unknown";
const EMPTY_UI_TABS: ReturnType<typeof useWorkspaceTabsStore.getState>["uiTabsByWorkspace"][string] = [];
const EMPTY_TAB_ORDER: string[] = [];
type WorkspaceScreenProps = {
serverId: string;
@@ -97,41 +94,6 @@ type WorkspaceScreenProps = {
routeTabId?: string | null;
};
function applyWorkspaceTabOrder(input: {
tabs: WorkspaceTabDescriptor[];
keys: string[];
}): WorkspaceTabDescriptor[] {
if (input.keys.length === 0) {
return input.tabs;
}
const byKey = new Map<string, WorkspaceTabDescriptor>();
for (const tab of input.tabs) {
byKey.set(tab.key, tab);
}
const used = new Set<string>();
const next: WorkspaceTabDescriptor[] = [];
for (const key of input.keys) {
const tab = byKey.get(key);
if (!tab) {
continue;
}
used.add(key);
next.push(tab);
}
for (const tab of input.tabs) {
if (used.has(tab.key)) {
continue;
}
next.push(tab);
}
return next;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -148,57 +110,6 @@ function decodeSegment(value: string): string {
}
}
function formatProviderLabel(provider: Agent["provider"]): string {
if (provider === "claude") {
return "Claude";
}
if (provider === "codex") {
return "Codex";
}
if (!provider) {
return "Agent";
}
return provider.charAt(0).toUpperCase() + provider.slice(1);
}
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string {
if (typeof title !== "string") {
return "Agent";
}
const normalized = title.trim();
if (!normalized) {
return "Agent";
}
if (normalized.toLowerCase() === "new agent") {
return "Agent";
}
return normalized;
}
function resolveTabAvailability(input: {
tab: WorkspaceTabTarget;
agentsHydrated: boolean;
terminalsHydrated: boolean;
agentsById: Map<string, Agent>;
terminalIds: Set<string>;
}): TabAvailability {
if (input.tab.kind === "draft") {
return "available";
}
if (input.tab.kind === "agent") {
if (!input.agentsHydrated) {
return "unknown";
}
return input.agentsById.has(input.tab.agentId) ? "available" : "invalid";
}
if (input.tab.kind === "file") {
return "available";
}
if (!input.terminalsHydrated) {
return "unknown";
}
return input.terminalIds.has(input.tab.terminalId) ? "available" : "invalid";
}
export function WorkspaceScreen({
serverId,
@@ -392,11 +303,6 @@ function WorkspaceScreenContent({
: null;
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
const areWorkspaceAgentsHydrated = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false
);
const areWorkspaceTerminalsHydrated = terminalsQuery.isSuccess;
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore(
(state) => state.desktop.fileExplorerOpen
@@ -473,14 +379,6 @@ function WorkspaceScreenContent({
const agentsById = workspaceAgentVisibility.lookupById;
const terminalIds = useMemo(() => {
const set = new Set<string>();
for (const terminal of terminals) {
set.add(terminal.id);
}
return set;
}, [terminals]);
const persistenceKey = useMemo(
() =>
buildWorkspaceTabPersistenceKey({
@@ -490,119 +388,30 @@ function WorkspaceScreenContent({
[normalizedServerId, normalizedWorkspaceId]
);
const openTabs = useWorkspaceTabsStore((state) =>
const uiTabs = useWorkspaceTabsStore((state) =>
persistenceKey
? state.openTabsByWorkspace[persistenceKey] ?? EMPTY_WORKSPACE_TABS
: EMPTY_WORKSPACE_TABS
? state.uiTabsByWorkspace[persistenceKey] ?? EMPTY_UI_TABS
: EMPTY_UI_TABS
);
const tabOrder = useWorkspaceTabsStore((state) =>
persistenceKey
? state.tabOrderByWorkspace[persistenceKey] ?? EMPTY_TAB_ORDER
: EMPTY_TAB_ORDER
);
const openTabIdSet = useMemo(() => new Set(openTabs.map((tab) => tab.tabId)), [openTabs]);
const focusedTabId = useWorkspaceTabsStore((state) =>
persistenceKey ? state.focusedTabIdByWorkspace[persistenceKey] ?? "" : ""
);
const openDraftTab = useWorkspaceTabsStore((state) => state.openDraftTab);
const seedWorkspaceTabs = useWorkspaceTabsStore((state) => state.seedWorkspaceTabs);
const openOrFocusTab = useWorkspaceTabsStore((state) => state.openOrFocusTab);
const focusTab = useWorkspaceTabsStore((state) => state.focusTab);
const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab);
const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
const replaceWorkspaceTabTarget = useWorkspaceTabsStore((state) => state.replaceTabTarget);
useEffect(() => {
if (!normalizedServerId || !normalizedWorkspaceId) {
return;
}
if (openTabs.length > 0) {
return;
}
if (!areWorkspaceAgentsHydrated || !areWorkspaceTerminalsHydrated) {
return;
}
const targets: WorkspaceTabTarget[] = [];
for (const agent of workspaceAgents) {
targets.push({ kind: "agent", agentId: agent.id });
}
for (const terminal of terminals) {
targets.push({ kind: "terminal", terminalId: terminal.id });
}
seedWorkspaceTabs({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
targets,
focusedTabId: focusedTabId || null,
});
}, [
areWorkspaceAgentsHydrated,
areWorkspaceTerminalsHydrated,
focusedTabId,
normalizedServerId,
normalizedWorkspaceId,
openTabs.length,
seedWorkspaceTabs,
terminals,
workspaceAgents,
]);
useEffect(() => {
const normalized = typeof routeTabId === "string" ? routeTabId.trim() : "";
if (!normalized || !persistenceKey) {
return;
}
const alreadyOpen = openTabs.some((tab) => tab.tabId === normalized);
if (alreadyOpen) {
focusTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: normalized });
return;
}
// If the canonical tab route is opened without local state (fresh load / reconnect),
// reconstruct the tab target from the tabId prefix.
if (normalized.startsWith("agent_")) {
const agentId = normalized.slice("agent_".length).trim();
if (agentId) {
if (
!canOpenAgentTabFromRoute({
agentId,
agentsHydrated: areWorkspaceAgentsHydrated,
workspaceAgentLookup: agentsById,
})
) {
return;
}
const tabId = openOrFocusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
target: { kind: "agent", agentId },
});
if (tabId) {
focusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId,
});
}
}
return;
}
if (normalized.startsWith("terminal_")) {
const terminalId = normalized.slice("terminal_".length).trim();
if (terminalId) {
if (areWorkspaceTerminalsHydrated && !terminalIds.has(terminalId)) {
return;
}
const tabId = openOrFocusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
target: { kind: "terminal", terminalId },
});
if (tabId) {
focusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId,
});
}
}
return;
}
if (normalized.startsWith("draft_")) {
const tabId = openDraftTab({
serverId: normalizedServerId,
@@ -616,75 +425,49 @@ function WorkspaceScreenContent({
tabId,
});
}
return;
}
if (normalized.startsWith("file_")) {
const path = normalized.slice("file_".length).trim();
if (!path) {
return;
}
const tabId = openOrFocusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
target: { kind: "file", path },
});
if (tabId) {
focusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId,
});
}
}
}, [
focusTab,
agentsById,
areWorkspaceAgentsHydrated,
areWorkspaceTerminalsHydrated,
openDraftTab,
openOrFocusTab,
openTabs,
persistenceKey,
normalizedServerId,
normalizedWorkspaceId,
routeTabId,
terminalIds,
]);
const activeTabId = useMemo(() => {
const fromRoute =
typeof routeTabId === "string" ? trimNonEmpty(routeTabId.trim()) : null;
if (fromRoute && openTabIdSet.has(fromRoute)) {
return fromRoute;
}
const fromFocus = trimNonEmpty(focusedTabId);
if (fromFocus && openTabIdSet.has(fromFocus)) {
return fromFocus;
}
return openTabs[0]?.tabId ?? null;
}, [focusedTabId, openTabIdSet, openTabs, routeTabId]);
useEffect(() => {
if (!persistenceKey) {
return;
}
const invalidTabIds = openTabs
.filter((tab) => {
const availability = resolveTabAvailability({
tab: tab.target,
agentsHydrated: areWorkspaceAgentsHydrated,
terminalsHydrated: areWorkspaceTerminalsHydrated,
agentsById,
terminalIds,
});
return availability === "invalid";
})
.map((tab) => tab.tabId);
if (invalidTabIds.length === 0) {
return;
}
for (const tabId of invalidTabIds) {
closeWorkspaceTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId,
});
}
}, [
agentsById,
areWorkspaceAgentsHydrated,
areWorkspaceTerminalsHydrated,
closeWorkspaceTab,
normalizedServerId,
normalizedWorkspaceId,
openTabs,
persistenceKey,
terminalIds,
]);
const tabModel = useMemo(
() =>
deriveWorkspaceTabModel({
workspaceAgents,
terminals,
uiTabs,
tabOrder,
focusedTabId,
routeTabId,
}),
[focusedTabId, routeTabId, tabOrder, terminals, uiTabs, workspaceAgents]
);
const activeTabId = tabModel.activeTabId;
useEffect(() => {
if (!activeTabId || !persistenceKey) {
@@ -711,83 +494,12 @@ function WorkspaceScreenContent({
);
}, [activeTabId, normalizedServerId, normalizedWorkspaceId, router, routeTabId]);
const activeTab = useMemo(() => openTabs.find((tab) => tab.tabId === activeTabId) ?? null, [
activeTabId,
openTabs,
]);
const activeTabAvailability = useMemo(() => {
if (!activeTab) {
return "unknown" as TabAvailability;
}
return resolveTabAvailability({
tab: activeTab.target,
agentsHydrated: areWorkspaceAgentsHydrated,
terminalsHydrated: areWorkspaceTerminalsHydrated,
agentsById,
terminalIds,
});
}, [
activeTab,
agentsById,
areWorkspaceAgentsHydrated,
areWorkspaceTerminalsHydrated,
terminalIds,
]);
const activeTab = tabModel.activeTab;
const tabs = useMemo<WorkspaceTabDescriptor[]>(() => {
const next: WorkspaceTabDescriptor[] = [];
for (const tab of openTabs) {
const target = tab.target;
if (target.kind === "draft") {
next.push({
key: tab.tabId,
tabId: tab.tabId,
kind: "draft",
draftId: target.draftId,
label: "Draft",
subtitle: "Draft",
});
continue;
}
if (target.kind === "agent") {
const agent = sessionAgents?.get(target.agentId) ?? null;
const provider = agent?.provider ?? "claude";
next.push({
key: tab.tabId,
tabId: tab.tabId,
kind: "agent",
agentId: target.agentId,
provider,
label: resolveWorkspaceAgentTabLabel(agent?.title),
subtitle: `${formatProviderLabel(provider)} agent`,
});
continue;
}
if (target.kind === "terminal") {
const terminal = terminals.find((t) => t.id === target.terminalId) ?? null;
next.push({
key: tab.tabId,
tabId: tab.tabId,
kind: "terminal",
terminalId: target.terminalId,
label: terminal?.name ?? "Terminal",
subtitle: "Terminal",
});
continue;
}
const filePath = target.path;
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;
next.push({
key: tab.tabId,
tabId: tab.tabId,
kind: "file",
filePath,
label: fileName,
subtitle: filePath,
});
}
return next;
}, [openTabs, sessionAgents, terminals]);
const tabs = useMemo<WorkspaceTabDescriptor[]>(
() => tabModel.tabs.map((tab) => tab.descriptor),
[tabModel.tabs]
);
const handleReorderTabs = useCallback(
(nextTabs: WorkspaceTabDescriptor[]) => {
@@ -851,7 +563,7 @@ function WorkspaceScreenContent({
() =>
tabs.map((tab) => ({
id: tab.key,
label: tab.label,
label: tab.kind === "agent" && tab.titleState === "loading" ? "Loading..." : tab.label,
description: tab.subtitle,
})),
[tabs]
@@ -859,6 +571,9 @@ function WorkspaceScreenContent({
const activeTabLabel = useMemo(() => {
const active = tabs.find((tab) => tab.key === activeTabKey);
if (active?.kind === "agent" && active.titleState === "loading") {
return "Loading...";
}
return active?.label ?? "Select tab";
}, [activeTabKey, tabs]);
@@ -1218,15 +933,6 @@ function WorkspaceScreenContent({
</View>
);
}
if (activeTabAvailability === "invalid") {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>
This tab is no longer available. It will be removed from your workspace.
</Text>
</View>
);
}
if (target.kind === "draft") {
return (
@@ -1237,19 +943,26 @@ function WorkspaceScreenContent({
draftId={target.draftId}
onCreated={(agentSnapshot) => {
const tabId = activeTabId ?? target.draftId;
replaceWorkspaceTabTarget({
const nextAgentTabId = openOrFocusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
target: { kind: "agent", agentId: agentSnapshot.id },
});
closeWorkspaceTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId,
target: { kind: "agent", agentId: agentSnapshot.id },
});
if (nextAgentTabId) {
navigateToTabId(nextAgentTabId);
}
}}
/>
);
}
if (target.kind === "agent") {
return (
return (
<AgentReadyScreen
serverId={normalizedServerId}
agentId={target.agentId}
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
function makeAgent(input: {
id: string;
provider?: Agent["provider"];
title?: string | null;
createdAt?: Date;
lastActivityAt?: Date;
requiresAttention?: boolean;
attentionReason?: Agent["attentionReason"];
}): Agent {
const createdAt = input.createdAt ?? new Date("2026-03-04T00:00:00.000Z");
const lastActivityAt = input.lastActivityAt ?? createdAt;
return {
serverId: "srv",
id: input.id,
provider: input.provider ?? "codex",
status: "idle",
createdAt,
updatedAt: createdAt,
lastUserMessageAt: null,
lastActivityAt,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider: input.provider ?? "codex",
sessionId: null,
},
title: input.title ?? null,
cwd: "/repo/worktree",
model: null,
thinkingOptionId: null,
labels: {},
requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null,
attentionTimestamp: null,
archivedAt: null,
};
}
describe("deriveWorkspaceTabModel", () => {
it("derives agent and terminal tabs from domain state, not UI membership", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [
makeAgent({ id: "agent-a", title: "Build API" }),
makeAgent({ id: "agent-b", title: "" }),
],
terminals: [{ id: "term-1", name: "shell" }],
uiTabs: [],
tabOrder: [],
});
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
"agent_agent-a",
"agent_agent-b",
"terminal_term-1",
]);
const firstAgent = model.tabs[0]?.descriptor;
const secondAgent = model.tabs[1]?.descriptor;
expect(firstAgent?.kind === "agent" ? firstAgent.titleState : null).toBe("ready");
expect(secondAgent?.kind === "agent" ? secondAgent.titleState : null).toBe("loading");
expect(secondAgent?.label).toBe("");
});
it("keeps draft and file tabs as explicit UI tabs", () => {
const uiTabs: WorkspaceTab[] = [
{
tabId: "draft_123",
target: { kind: "draft", draftId: "draft_123" },
createdAt: 1,
},
{
tabId: "file_/repo/worktree/README.md",
target: { kind: "file", path: "/repo/worktree/README.md" },
createdAt: 2,
},
];
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a", title: "A" })],
terminals: [],
uiTabs,
tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"],
});
expect(model.tabs.map((tab) => tab.descriptor.kind)).toEqual(["draft", "agent", "file"]);
});
it("applies stored order and appends newly-derived tabs deterministically", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [{ id: "term-1", name: "zsh" }],
uiTabs: [],
tabOrder: ["terminal_term-1", "agent_agent-b"],
});
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
"terminal_term-1",
"agent_agent-b",
"agent_agent-a",
]);
});
it("prefers route tab, then focused tab, then first tab for active selection", () => {
const base = {
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [],
uiTabs: [],
tabOrder: ["agent_agent-a", "agent_agent-b"],
};
expect(
deriveWorkspaceTabModel({
...base,
focusedTabId: "agent_agent-b",
}).activeTabId
).toBe("agent_agent-b");
expect(
deriveWorkspaceTabModel({
...base,
focusedTabId: "agent_agent-b",
routeTabId: "agent_agent-a",
}).activeTabId
).toBe("agent_agent-a");
});
it("covers regression: non-archived attention agent remains visible even if UI tabs omitted it", () => {
const offending = makeAgent({
id: "offender",
title: "Needs permission",
requiresAttention: true,
attentionReason: "permission",
});
const model = deriveWorkspaceTabModel({
workspaceAgents: [offending],
terminals: [],
uiTabs: [
{
tabId: "draft_123",
target: { kind: "draft", draftId: "draft_123" },
createdAt: 1,
},
],
tabOrder: ["draft_123"],
});
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(true);
});
});
@@ -0,0 +1,230 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
type TerminalLike = {
id: string;
name?: string | null;
};
export type WorkspaceDerivedTab = {
descriptor: WorkspaceTabDescriptor;
target: WorkspaceTabTarget;
};
export type WorkspaceTabModel = {
tabs: WorkspaceDerivedTab[];
activeTabId: string | null;
activeTab: WorkspaceDerivedTab | null;
};
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 formatProviderLabel(provider: Agent["provider"]): string {
if (provider === "claude") {
return "Claude";
}
if (provider === "codex") {
return "Codex";
}
if (!provider) {
return "Agent";
}
return provider.charAt(0).toUpperCase() + provider.slice(1);
}
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null {
if (typeof title !== "string") {
return null;
}
const normalized = title.trim();
if (!normalized) {
return null;
}
if (normalized.toLowerCase() === "new agent") {
return null;
}
return normalized;
}
function normalizeUiTab(tab: WorkspaceTab): WorkspaceTab | null {
if (!tab || typeof tab !== "object") {
return null;
}
const tabId = trimNonEmpty(tab.tabId);
if (!tabId) {
return null;
}
if (!tab.target || typeof tab.target !== "object") {
return null;
}
if (tab.target.kind === "draft") {
const draftId = trimNonEmpty(tab.target.draftId);
if (!draftId) {
return null;
}
return {
tabId,
target: { kind: "draft", draftId },
createdAt: tab.createdAt,
};
}
if (tab.target.kind === "file") {
const path = trimNonEmpty(tab.target.path);
if (!path) {
return null;
}
return {
tabId,
target: { kind: "file", path: path.replace(/\\/g, "/") },
createdAt: tab.createdAt,
};
}
return null;
}
export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
if (target.kind === "draft") {
return target.draftId;
}
if (target.kind === "agent") {
return `agent_${target.agentId}`;
}
if (target.kind === "terminal") {
return `terminal_${target.terminalId}`;
}
return `file_${target.path}`;
}
export function deriveWorkspaceTabModel(input: {
workspaceAgents: Agent[];
terminals: TerminalLike[];
uiTabs: WorkspaceTab[];
tabOrder: string[];
focusedTabId?: string | null;
routeTabId?: string | null;
}): WorkspaceTabModel {
const tabsById = new Map<string, WorkspaceDerivedTab>();
for (const agent of input.workspaceAgents) {
const target: WorkspaceTabTarget = { kind: "agent", agentId: agent.id };
const tabId = buildWorkspaceTabId(target);
const label = resolveWorkspaceAgentTabLabel(agent.title);
tabsById.set(tabId, {
target,
descriptor: {
key: tabId,
tabId,
kind: "agent",
agentId: agent.id,
provider: agent.provider,
label: label ?? "",
subtitle: `${formatProviderLabel(agent.provider)} agent`,
titleState: label ? "ready" : "loading",
},
});
}
for (const terminal of input.terminals) {
const target: WorkspaceTabTarget = { kind: "terminal", terminalId: terminal.id };
const tabId = buildWorkspaceTabId(target);
tabsById.set(tabId, {
target,
descriptor: {
key: tabId,
tabId,
kind: "terminal",
terminalId: terminal.id,
label: trimNonEmpty(terminal.name) ?? "Terminal",
subtitle: "Terminal",
},
});
}
const normalizedUiTabs = input.uiTabs
.map((tab) => normalizeUiTab(tab))
.filter((tab): tab is WorkspaceTab => tab !== null)
.sort((left, right) => left.createdAt - right.createdAt);
for (const tab of normalizedUiTabs) {
if (tab.target.kind === "draft") {
tabsById.set(tab.tabId, {
target: tab.target,
descriptor: {
key: tab.tabId,
tabId: tab.tabId,
kind: "draft",
draftId: tab.target.draftId,
label: "Draft",
subtitle: "Draft",
},
});
continue;
}
if (tab.target.kind === "file") {
const filePath = tab.target.path;
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;
tabsById.set(tab.tabId, {
target: tab.target,
descriptor: {
key: tab.tabId,
tabId: tab.tabId,
kind: "file",
filePath,
label: fileName,
subtitle: filePath,
},
});
}
}
const orderedTabIds: string[] = [];
const used = new Set<string>();
for (const tabId of input.tabOrder) {
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedTabId || used.has(normalizedTabId) || !tabsById.has(normalizedTabId)) {
continue;
}
used.add(normalizedTabId);
orderedTabIds.push(normalizedTabId);
}
for (const tabId of tabsById.keys()) {
if (used.has(tabId)) {
continue;
}
used.add(tabId);
orderedTabIds.push(tabId);
}
const tabs = orderedTabIds
.map((tabId) => tabsById.get(tabId) ?? null)
.filter((tab): tab is WorkspaceDerivedTab => tab !== null);
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
const routeTabId = trimNonEmpty(input.routeTabId);
const focusedTabId = trimNonEmpty(input.focusedTabId);
const activeTabId = routeTabId && openTabIds.has(routeTabId)
? routeTabId
: focusedTabId && openTabIds.has(focusedTabId)
? focusedTabId
: tabs[0]?.descriptor.tabId ?? null;
const activeTab = activeTabId
? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
: null;
return {
tabs,
activeTabId,
activeTab,
};
}
@@ -17,6 +17,7 @@ export type WorkspaceTabDescriptor =
provider: Agent["provider"];
label: string;
subtitle: string;
titleState: "ready" | "loading";
}
| {
key: string;