refactor: unify workspace tab management and add runtime metrics

This commit is contained in:
Mohamed Boudra
2026-03-05 14:21:16 +07:00
parent 6dc49ccbc7
commit 8371ea0c7d
32 changed files with 1064 additions and 461 deletions
@@ -6,7 +6,6 @@ import {
Platform,
BackHandler,
} from "react-native";
import { useRouter, type Href } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQueryClient } from "@tanstack/react-query";
@@ -60,7 +59,6 @@ import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
import { buildHostRootRoute } from "@/utils/host-routes";
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
@@ -187,7 +185,6 @@ function AgentScreenContent({
const { theme } = useUnistyles();
const toast = useToast();
const insets = useSafeAreaInsets();
const router = useRouter();
const queryClient = useQueryClient();
const resolvedAgentId = agentId;
const { isArchivingAgent } = useArchiveAgent();
@@ -514,24 +511,6 @@ function AgentScreenContent({
resolvedAgentId &&
isArchivingAgent({ serverId, agentId: resolvedAgentId })
);
const hasRedirectedArchivedAgentRef = useRef(false);
useEffect(() => {
if (!resolvedAgentId) {
hasRedirectedArchivedAgentRef.current = false;
return;
}
if (!agent?.archivedAt) {
hasRedirectedArchivedAgentRef.current = false;
return;
}
if (hasRedirectedArchivedAgentRef.current) {
return;
}
hasRedirectedArchivedAgentRef.current = true;
const route: Href = buildHostRootRoute(serverId) as Href;
router.replace(route);
}, [agent?.archivedAt, resolvedAgentId, router, serverId]);
useEffect(() => {
if (!resolvedAgentId) {
@@ -6,7 +6,7 @@ import {
} from "@/screens/workspace/workspace-tab-layout";
type UseWorkspaceTabLayoutInput = {
tabLabels: string[];
tabLabelLengths: number[];
viewportWidthOverride?: number | null;
metrics: {
rowHorizontalInset: number;
@@ -36,10 +36,10 @@ export function useWorkspaceTabLayout(input: UseWorkspaceTabLayoutInput): UseWor
() =>
computeWorkspaceTabLayout({
viewportWidth: resolvedViewportWidth,
tabLabelLengths: input.tabLabels.map((label) => label.length),
tabLabelLengths: input.tabLabelLengths,
metrics: input.metrics,
}),
[input.metrics, input.tabLabels, resolvedViewportWidth]
[input.metrics, input.tabLabelLengths, resolvedViewportWidth]
);
return {
@@ -21,6 +21,7 @@ import { encodeFilePathForPathSegment } from "@/utils/host-routes";
import type { Agent } from "@/stores/session-store";
const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__";
type WorkspaceDesktopTabsRowProps = {
@@ -101,8 +102,19 @@ export function WorkspaceDesktopTabsRow({
[tabsActionsWidth, theme.spacing]
);
const tabLabelLengths = useMemo(
() =>
tabs.map((tab) => {
if (tab.kind === "agent" && tab.titleState === "loading") {
return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth));
}
return tab.label.length;
}),
[layoutMetrics.estimatedCharWidth, tabs]
);
const { layout } = useWorkspaceTabLayout({
tabLabels: tabs.map((tab) => tab.label),
tabLabelLengths,
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
metrics: layoutMetrics,
});
@@ -478,7 +490,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.9,
},
tabLabelSkeletonWithCloseButton: {
width: 80,
width: LOADING_TAB_LABEL_SKELETON_WIDTH,
},
tabLabelWithCloseButton: {
paddingRight: 0,
@@ -10,12 +10,11 @@ import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useAgentFormState } from "@/hooks/use-agent-form-state";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { Agent } from "@/stores/session-store";
import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from "@/types/stream";
import { encodeImages } from "@/utils/encode-images";
import type { AgentCapabilityFlags, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
@@ -268,13 +267,6 @@ export function WorkspaceDraftAgentTab({
const agentId = result.id;
updatePendingAgentId({ draftId, agentId });
const normalized = normalizeAgentSnapshot(result, serverId);
useSessionStore.getState().setAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, normalized);
return next;
});
onCreated(result);
return;
} catch (error) {
@@ -403,6 +395,7 @@ const styles = StyleSheet.create((theme) => ({
width: "100%",
alignSelf: "center",
maxWidth: MAX_CONTENT_WIDTH,
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flex: 1,
@@ -423,6 +416,7 @@ const styles = StyleSheet.create((theme) => ({
},
inputAreaWrapper: {
width: "100%",
backgroundColor: theme.colors.surface0,
},
errorContainer: {
marginTop: theme.spacing[2],
@@ -5,6 +5,7 @@ import {
Platform,
Pressable,
Text,
useColorScheme,
View,
} from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -49,6 +50,7 @@ import {
useWorkspaceTabsStore,
} from "@/stores/workspace-tabs-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import {
buildWorkspaceOpenIntentParam,
type WorkspaceOpenIntent,
@@ -69,6 +71,7 @@ import { getStatusDotColor } from "@/utils/status-dot-color";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
@@ -133,6 +136,8 @@ function WorkspaceScreenContent({
openIntent,
}: WorkspaceScreenProps) {
const { theme } = useUnistyles();
const isDarkMode = useColorScheme() === "dark";
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
const toast = useToast();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -404,11 +409,13 @@ function WorkspaceScreenContent({
persistenceKey ? state.focusedTabIdByWorkspace[persistenceKey] ?? "" : ""
);
const openDraftTab = useWorkspaceTabsStore((state) => state.openDraftTab);
const ensureTab = useWorkspaceTabsStore((state) => state.ensureTab);
const openOrFocusTab = useWorkspaceTabsStore((state) => state.openOrFocusTab);
const focusTab = useWorkspaceTabsStore((state) => state.focusTab);
const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab);
const promoteDraftToAgent = useWorkspaceTabsStore((state) => state.promoteDraftToAgent);
const retargetWorkspaceTab = useWorkspaceTabsStore((state) => state.retargetTab);
const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const workspaceTabActionRequest = useKeyboardShortcutsStore(
(state) => state.workspaceTabActionRequest
);
@@ -483,12 +490,90 @@ function WorkspaceScreenContent({
normalizedWorkspaceId,
]);
useEffect(() => {
if (!normalizedServerId || !normalizedWorkspaceId) {
return;
}
const agentIds = new Set(workspaceAgents.map((agent) => agent.id));
const terminalIds = new Set(terminals.map((terminal) => terminal.id));
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
if (tab.target.kind !== "draft") {
return false;
}
const pending = pendingByDraftId[tab.target.draftId];
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
});
for (const agent of workspaceAgents) {
const representedByTarget = uiTabs.some(
(tab) => tab.target.kind === "agent" && tab.target.agentId === agent.id
);
const representedByDeterministicTabId = uiTabs.some(
(tab) => tab.tabId === `agent_${agent.id}`
);
if (
hasActivePendingDraftCreateInWorkspace &&
!representedByTarget &&
!representedByDeterministicTabId
) {
continue;
}
ensureTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
target: { kind: "agent", agentId: agent.id },
});
}
for (const terminal of terminals) {
ensureTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
target: { kind: "terminal", terminalId: terminal.id },
});
}
const canPruneAgentTabs = hasHydratedAgents;
const canPruneTerminalTabs = terminalsQuery.isSuccess;
for (const tab of uiTabs) {
if (canPruneAgentTabs && tab.target.kind === "agent" && !agentIds.has(tab.target.agentId)) {
closeWorkspaceTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: tab.tabId,
});
}
if (
canPruneTerminalTabs &&
tab.target.kind === "terminal" &&
!terminalIds.has(tab.target.terminalId)
) {
closeWorkspaceTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: tab.tabId,
});
}
}
}, [
closeWorkspaceTab,
ensureTab,
hasHydratedAgents,
normalizedServerId,
normalizedWorkspaceId,
pendingByDraftId,
terminals,
terminalsQuery.isSuccess,
uiTabs,
workspaceAgents,
]);
const tabModel = useMemo(
() =>
deriveWorkspaceTabModel({
workspaceAgents,
terminals,
uiTabs,
tabs: uiTabs,
tabOrder,
focusedTabId,
}),
@@ -540,6 +625,10 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
return;
}
if (workspaceAgents.length > 0 || terminals.length > 0) {
emptyWorkspaceSeedRef.current = null;
return;
}
if (tabs.length > 0) {
emptyWorkspaceSeedRef.current = null;
return;
@@ -564,7 +653,9 @@ function WorkspaceScreenContent({
normalizedWorkspaceId,
openDraftTab,
persistenceKey,
terminals.length,
tabs.length,
workspaceAgents.length,
]);
const handleOpenFileFromExplorer = useCallback(
@@ -1047,15 +1138,25 @@ function WorkspaceScreenContent({
draftId={target.draftId}
onCreated={(agentSnapshot) => {
const tabId = activeTabId ?? target.draftId;
const nextAgentTabId = promoteDraftToAgent({
const normalized = normalizeAgentSnapshot(agentSnapshot, normalizedServerId);
const nextTabId = retargetWorkspaceTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
draftTabId: tabId,
agentId: agentSnapshot.id,
tabId,
target: { kind: "agent", agentId: agentSnapshot.id },
});
if (nextAgentTabId) {
navigateToTabId(nextAgentTabId);
if (nextTabId) {
focusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: nextTabId,
});
}
useSessionStore.getState().setAgents(normalizedServerId, (prev) => {
const next = new Map(prev);
next.set(agentSnapshot.id, normalized);
return next;
});
}}
/>
);
@@ -1107,7 +1208,7 @@ function WorkspaceScreenContent({
};
return (
<View style={styles.container}>
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
<ScreenHeader
@@ -1654,6 +1755,7 @@ const styles = StyleSheet.create((theme) => ({
content: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surface0,
},
emptyState: {
flex: 1,
@@ -59,7 +59,11 @@ describe("deriveWorkspaceTabModel", () => {
makeAgent({ id: "agent-b", title: "" }),
],
terminals: [{ id: "term-1", name: "shell" }],
uiTabs: [],
tabs: [
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
{ tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 },
],
tabOrder: [],
});
@@ -92,7 +96,10 @@ describe("deriveWorkspaceTabModel", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a", title: "A" })],
terminals: [],
uiTabs,
tabs: [
...uiTabs,
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 3 },
],
tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"],
});
@@ -103,7 +110,11 @@ describe("deriveWorkspaceTabModel", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [{ id: "term-1", name: "zsh" }],
uiTabs: [],
tabs: [
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
{ tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 },
],
tabOrder: ["terminal_term-1", "agent_agent-b"],
});
@@ -115,10 +126,13 @@ describe("deriveWorkspaceTabModel", () => {
});
it("uses focused tab when present, otherwise falls back to first tab", () => {
const base = {
const base: Parameters<typeof deriveWorkspaceTabModel>[0] = {
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [],
uiTabs: [],
tabs: [
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
],
tabOrder: ["agent_agent-a", "agent_agent-b"],
};
@@ -141,7 +155,13 @@ describe("deriveWorkspaceTabModel", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })],
terminals: [],
uiTabs: [],
tabs: [
{
tabId: "agent_workspace-b-agent",
target: { kind: "agent", agentId: "workspace-b-agent" },
createdAt: 1,
},
],
tabOrder: ["agent_workspace-b-agent"],
focusedTabId: "agent_workspace-a-agent",
});
@@ -153,7 +173,7 @@ describe("deriveWorkspaceTabModel", () => {
});
});
it("covers regression: non-archived attention agent remains visible even if UI tabs omitted it", () => {
it("does not materialize tabs absent from workspace tab membership", () => {
const offending = makeAgent({
id: "offender",
title: "Needs permission",
@@ -164,7 +184,7 @@ describe("deriveWorkspaceTabModel", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [offending],
terminals: [],
uiTabs: [
tabs: [
{
tabId: "draft_123",
target: { kind: "draft", draftId: "draft_123" },
@@ -174,42 +194,47 @@ describe("deriveWorkspaceTabModel", () => {
tabOrder: ["draft_123"],
});
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(true);
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(false);
});
it("includes older attention and failure agents in workspace tabs when session data is complete", () => {
const olderPermissionAgent = makeAgent({
id: "agent-permission-old",
title: "Need permission",
createdAt: new Date("2026-01-15T00:00:00.000Z"),
requiresAttention: true,
attentionReason: "permission",
});
const olderFailedAgent = makeAgent({
id: "agent-failed-old",
title: "Failed run",
createdAt: new Date("2026-01-10T00:00:00.000Z"),
requiresAttention: true,
attentionReason: "error",
});
const newerAgent = makeAgent({
id: "agent-recent",
title: "Recent work",
createdAt: new Date("2026-03-04T00:00:00.000Z"),
});
it("keeps retargeted agent tab id stable while upgrading descriptor data", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [newerAgent, olderPermissionAgent, olderFailedAgent],
workspaceAgents: [],
terminals: [],
uiTabs: [],
tabOrder: [],
tabs: [
{
tabId: "draft_abc",
target: { kind: "agent", agentId: "agent-1" },
createdAt: 1,
},
],
tabOrder: ["draft_abc"],
});
const initial = model.tabs[0]?.descriptor;
expect(initial?.tabId).toBe("draft_abc");
expect(initial?.kind).toBe("agent");
if (initial?.kind === "agent") {
expect(initial.titleState).toBe("loading");
expect(initial.agentId).toBe("agent-1");
}
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_agent-permission-old")).toBe(
true
);
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_agent-failed-old")).toBe(
true
);
const upgraded = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })],
terminals: [],
tabs: [
{
tabId: "draft_abc",
target: { kind: "agent", agentId: "agent-1" },
createdAt: 1,
},
],
tabOrder: ["draft_abc"],
});
const upgradedDescriptor = upgraded.tabs[0]?.descriptor;
expect(upgradedDescriptor?.tabId).toBe("draft_abc");
if (upgradedDescriptor?.kind === "agent") {
expect(upgradedDescriptor.titleState).toBe("ready");
expect(upgradedDescriptor.label).toBe("Ready title");
}
});
});
@@ -53,7 +53,7 @@ function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string
return normalized;
}
function normalizeUiTab(tab: WorkspaceTab): WorkspaceTab | null {
function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
if (!tab || typeof tab !== "object") {
return null;
}
@@ -75,6 +75,28 @@ function normalizeUiTab(tab: WorkspaceTab): WorkspaceTab | null {
createdAt: tab.createdAt,
};
}
if (tab.target.kind === "agent") {
const agentId = trimNonEmpty(tab.target.agentId);
if (!agentId) {
return null;
}
return {
tabId,
target: { kind: "agent", agentId },
createdAt: tab.createdAt,
};
}
if (tab.target.kind === "terminal") {
const terminalId = trimNonEmpty(tab.target.terminalId);
if (!terminalId) {
return null;
}
return {
tabId,
target: { kind: "terminal", terminalId },
createdAt: tab.createdAt,
};
}
if (tab.target.kind === "file") {
const path = trimNonEmpty(tab.target.path);
if (!path) {
@@ -105,53 +127,20 @@ export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
export function deriveWorkspaceTabModel(input: {
workspaceAgents: Agent[];
terminals: TerminalLike[];
uiTabs: WorkspaceTab[];
tabs: WorkspaceTab[];
tabOrder: string[];
focusedTabId?: string | null;
}): WorkspaceTabModel {
const tabsById = new Map<string, WorkspaceDerivedTab>();
const agentsById = new Map(input.workspaceAgents.map((agent) => [agent.id, agent]));
const terminalsById = new Map(input.terminals.map((terminal) => [terminal.id, terminal]));
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))
const normalizedTabs = input.tabs
.map((tab) => normalizeWorkspaceTab(tab))
.filter((tab): tab is WorkspaceTab => tab !== null)
.sort((left, right) => left.createdAt - right.createdAt);
for (const tab of normalizedUiTabs) {
for (const tab of normalizedTabs) {
if (tab.target.kind === "draft") {
tabsById.set(tab.tabId, {
target: tab.target,
@@ -167,6 +156,42 @@ export function deriveWorkspaceTabModel(input: {
continue;
}
if (tab.target.kind === "agent") {
const agent = agentsById.get(tab.target.agentId) ?? null;
const label = resolveWorkspaceAgentTabLabel(agent?.title);
const provider = agent?.provider ?? "codex";
tabsById.set(tab.tabId, {
target: tab.target,
descriptor: {
key: tab.tabId,
tabId: tab.tabId,
kind: "agent",
agentId: tab.target.agentId,
provider,
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
},
});
continue;
}
if (tab.target.kind === "terminal") {
const terminal = terminalsById.get(tab.target.terminalId) ?? null;
tabsById.set(tab.tabId, {
target: tab.target,
descriptor: {
key: tab.tabId,
tabId: tab.tabId,
kind: "terminal",
terminalId: tab.target.terminalId,
label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal",
subtitle: "Terminal",
},
});
continue;
}
if (tab.target.kind === "file") {
const filePath = tab.target.path;
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;