refactor: unify workspace tab management and add runtime metrics
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { View, Pressable, Text, ActivityIndicator, Platform } from 'react-native'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { ArrowUp, Square, Pencil, AudioLines } from 'lucide-react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
@@ -119,6 +119,10 @@ export function AgentInputArea({
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead)
|
||||
|
||||
const [internalInput, setInternalInput] = useState('')
|
||||
const isDesktopWebBreakpoint =
|
||||
Platform.OS === 'web' &&
|
||||
UnistylesRuntime.breakpoint !== 'xs' &&
|
||||
UnistylesRuntime.breakpoint !== 'sm'
|
||||
const userInput = value ?? internalInput
|
||||
const setUserInput = onChangeText ?? setInternalInput
|
||||
const [cursorIndex, setCursorIndex] = useState(0)
|
||||
@@ -775,7 +779,8 @@ export function AgentInputArea({
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder="Message agent..."
|
||||
autoFocus={autoFocus}
|
||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isScreenFocused={isScreenFocused}
|
||||
leftContent={leftContent}
|
||||
|
||||
@@ -440,14 +440,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
overflow: "hidden",
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative",
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
resizeHandle: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -948,7 +948,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
|
||||
if (isStatusLoading) {
|
||||
bodyContent = (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" />
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.loadingText}>Checking repository...</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -967,8 +967,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
|
||||
} else if (isDiffLoading) {
|
||||
bodyContent = (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.loadingText}>Loading changes...</Text>
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
} else if (diffErrorMessage) {
|
||||
@@ -1234,9 +1233,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
|
||||
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
|
||||
{branchLabel}
|
||||
</Text>
|
||||
{isStatusFetching && (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
{isGit ? (
|
||||
<View style={styles.headerRight}>
|
||||
|
||||
@@ -560,7 +560,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sidebarContent: {
|
||||
@@ -571,7 +571,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
desktopSidebar: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
sidebarHeader: {
|
||||
height: {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '@/utils/image-attachments-from-files'
|
||||
import type { AttachmentMetadata } from '@/attachments/types'
|
||||
import { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-url'
|
||||
import { focusWithRetries } from '@/utils/web-focus'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Shortcut } from '@/components/ui/shortcut'
|
||||
import type { MessageInputKeyboardActionKind } from '@/keyboard/actions'
|
||||
@@ -62,6 +63,7 @@ export interface MessageInputProps {
|
||||
isReadyForDictation?: boolean
|
||||
placeholder?: string
|
||||
autoFocus?: boolean
|
||||
autoFocusKey?: string
|
||||
disabled?: boolean
|
||||
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */
|
||||
isScreenFocused?: boolean
|
||||
@@ -137,6 +139,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
isReadyForDictation,
|
||||
placeholder = 'Message...',
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
disabled = false,
|
||||
isScreenFocused = true,
|
||||
leftContent,
|
||||
@@ -231,15 +234,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
valueRef.current = value
|
||||
}, [value])
|
||||
|
||||
// Autofocus on web when autoFocus prop is true
|
||||
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || !autoFocus) return
|
||||
// Use requestAnimationFrame to ensure DOM is ready
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
textInputRef.current?.focus()
|
||||
return focusWithRetries({
|
||||
focus: () => textInputRef.current?.focus(),
|
||||
isFocused: () => {
|
||||
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null
|
||||
const native = typeof current?.getNativeRef === 'function' ? current.getNativeRef() : current
|
||||
const element = native instanceof HTMLElement ? native : null
|
||||
const active = typeof document !== 'undefined' ? document.activeElement : null
|
||||
return Boolean(element) && active === element
|
||||
},
|
||||
})
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [autoFocus])
|
||||
}, [autoFocus, autoFocusKey])
|
||||
|
||||
const handleDictationTranscript = useCallback(
|
||||
(text: string, _meta: { requestId: string }) => {
|
||||
|
||||
@@ -1181,7 +1181,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
workspaceListContainer: {
|
||||
marginLeft: theme.spacing[4],
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { buildWorkspaceTabPersistenceKey, useWorkspaceTabsStore } from "@/stores
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "/repo/worktree";
|
||||
|
||||
describe("workspace-tabs-store promoteDraftToAgent", () => {
|
||||
describe("workspace-tabs-store retargetTab", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceTabsStore.setState({
|
||||
uiTabsByWorkspace: {},
|
||||
@@ -29,55 +29,26 @@ describe("workspace-tabs-store promoteDraftToAgent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces draft tab id in order with agent tab id, preserves tab count, and removes draft UI tab", () => {
|
||||
it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => {
|
||||
const draftTabId = "draft_123";
|
||||
const agentId = "agent-1";
|
||||
const expectedAgentTabId = `agent_${agentId}`;
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
useWorkspaceTabsStore.getState().ensureTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "left" },
|
||||
});
|
||||
useWorkspaceTabsStore.getState().openDraftTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
});
|
||||
|
||||
const beforeOrder = useWorkspaceTabsStore.getState().tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
const promoted = useWorkspaceTabsStore.getState().promoteDraftToAgent({
|
||||
useWorkspaceTabsStore.getState().ensureTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftTabId,
|
||||
agentId,
|
||||
});
|
||||
|
||||
const state = useWorkspaceTabsStore.getState();
|
||||
const afterOrder = state.tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
|
||||
expect(promoted).toBe(expectedAgentTabId);
|
||||
expect(afterOrder).toEqual([expectedAgentTabId]);
|
||||
expect(afterOrder).toHaveLength(beforeOrder.length);
|
||||
expect(state.uiTabsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(state.focusedTabIdByWorkspace[workspaceKey]).toBe(expectedAgentTabId);
|
||||
});
|
||||
|
||||
it("does not create duplicate agent tab ids when agent tab already exists", () => {
|
||||
const draftTabId = "draft_456";
|
||||
const agentId = "agent-dup";
|
||||
const expectedAgentTabId = `agent_${agentId}`;
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
useWorkspaceTabsStore.getState().openDraftTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
});
|
||||
useWorkspaceTabsStore.getState().openOrFocusTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId },
|
||||
target: { kind: "agent", agentId: "right" },
|
||||
});
|
||||
useWorkspaceTabsStore.getState().focusTab({
|
||||
serverId: SERVER_ID,
|
||||
@@ -85,23 +56,84 @@ describe("workspace-tabs-store promoteDraftToAgent", () => {
|
||||
tabId: draftTabId,
|
||||
});
|
||||
|
||||
const beforeOrder = useWorkspaceTabsStore.getState().tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
const promoted = useWorkspaceTabsStore.getState().promoteDraftToAgent({
|
||||
const before = useWorkspaceTabsStore.getState();
|
||||
const beforeOrder = before.tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
|
||||
const retargeted = useWorkspaceTabsStore.getState().retargetTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftTabId,
|
||||
agentId,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created" },
|
||||
});
|
||||
|
||||
const after = useWorkspaceTabsStore.getState();
|
||||
const afterOrder = after.tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
const tabs = after.uiTabsByWorkspace[workspaceKey] ?? [];
|
||||
const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null;
|
||||
|
||||
expect(retargeted).toBe(draftTabId);
|
||||
expect(afterOrder).toEqual(beforeOrder);
|
||||
expect(after.focusedTabIdByWorkspace[workspaceKey]).toBe(draftTabId);
|
||||
expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" });
|
||||
});
|
||||
|
||||
it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
const terminalTabId = useWorkspaceTabsStore.getState().ensureTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
});
|
||||
expect(terminalTabId).toBe("terminal_term-1");
|
||||
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBeUndefined();
|
||||
|
||||
const focusedTabId = useWorkspaceTabsStore.getState().openOrFocusTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
});
|
||||
expect(focusedTabId).toBe("terminal_term-1");
|
||||
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(
|
||||
"terminal_term-1"
|
||||
);
|
||||
});
|
||||
|
||||
it("ensureTab deduplicates by target when a retargeted tab already exists", () => {
|
||||
const draftTabId = "draft_x";
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
useWorkspaceTabsStore.getState().openDraftTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
});
|
||||
useWorkspaceTabsStore.getState().retargetTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
});
|
||||
|
||||
const ensured = useWorkspaceTabsStore.getState().ensureTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
});
|
||||
|
||||
const state = useWorkspaceTabsStore.getState();
|
||||
const afterOrder = state.tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
const duplicateCount = afterOrder.filter((tabId) => tabId === expectedAgentTabId).length;
|
||||
const tabs = state.uiTabsByWorkspace[workspaceKey] ?? [];
|
||||
const order = state.tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
const matchingTabs = tabs.filter(
|
||||
(tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent"
|
||||
);
|
||||
|
||||
expect(promoted).toBe(expectedAgentTabId);
|
||||
expect(duplicateCount).toBe(1);
|
||||
expect(afterOrder).toEqual([expectedAgentTabId]);
|
||||
expect(afterOrder.length).toBeLessThanOrEqual(beforeOrder.length);
|
||||
expect(state.focusedTabIdByWorkspace[workspaceKey]).toBe(expectedAgentTabId);
|
||||
expect(state.uiTabsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(ensured).toBe(draftTabId);
|
||||
expect(matchingTabs).toHaveLength(1);
|
||||
expect(order).toEqual([draftTabId]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,8 +61,23 @@ function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): Works
|
||||
return null;
|
||||
}
|
||||
|
||||
function isUiTarget(target: WorkspaceTabTarget): target is Extract<WorkspaceTabTarget, { kind: "draft" | "file" }> {
|
||||
return target.kind === "draft" || target.kind === "file";
|
||||
function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "draft" && right.kind === "draft") {
|
||||
return left.draftId === right.draftId;
|
||||
}
|
||||
if (left.kind === "agent" && right.kind === "agent") {
|
||||
return left.agentId === right.agentId;
|
||||
}
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
||||
@@ -107,6 +122,11 @@ type WorkspaceTabsState = {
|
||||
tabOrderByWorkspace: Record<string, string[]>;
|
||||
focusedTabIdByWorkspace: Record<string, string>;
|
||||
openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string }) => string | null;
|
||||
ensureTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
openOrFocusTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -114,11 +134,11 @@ type WorkspaceTabsState = {
|
||||
}) => string | null;
|
||||
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||
promoteDraftToAgent: (input: {
|
||||
retargetTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
draftTabId: string;
|
||||
agentId: string;
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
|
||||
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
|
||||
@@ -141,39 +161,39 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
target: { kind: "draft", draftId: normalizedDraftId },
|
||||
});
|
||||
},
|
||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||
ensureTab: ({ serverId, workspaceId, target }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedTarget = normalizeTabTarget(target);
|
||||
if (!key || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabId = buildDeterministicTabId(normalizedTarget);
|
||||
const deterministicTabId = buildDeterministicTabId(normalizedTarget);
|
||||
let resolvedTabId = deterministicTabId;
|
||||
const now = Date.now();
|
||||
|
||||
set((state) => {
|
||||
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||
const nextOrder = ensureInOrder({ current: currentOrder, tabId });
|
||||
|
||||
if (!isUiTarget(normalizedTarget)) {
|
||||
return {
|
||||
tabOrderByWorkspace:
|
||||
nextOrder === currentOrder
|
||||
? state.tabOrderByWorkspace
|
||||
: { ...state.tabOrderByWorkspace, [key]: nextOrder },
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[key]: tabId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
const existingIndex = currentTabs.findIndex((tab) => tab.tabId === tabId);
|
||||
const nextTabs =
|
||||
existingIndex >= 0
|
||||
? currentTabs
|
||||
: [...currentTabs, { tabId, target: normalizedTarget, createdAt: now }];
|
||||
const tabWithSameTarget =
|
||||
currentTabs.find((tab) => tabTargetsEqual(tab.target, normalizedTarget)) ?? null;
|
||||
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
|
||||
resolvedTabId = effectiveTabId;
|
||||
|
||||
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||
const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId });
|
||||
const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId);
|
||||
const nextTabs = (() => {
|
||||
if (existingIndex < 0) {
|
||||
return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt: now }];
|
||||
}
|
||||
const existing = currentTabs[existingIndex];
|
||||
if (existing && tabTargetsEqual(existing.target, normalizedTarget)) {
|
||||
return currentTabs;
|
||||
}
|
||||
return currentTabs.map((tab, index) =>
|
||||
index === existingIndex ? { ...tab, target: normalizedTarget } : tab
|
||||
);
|
||||
})();
|
||||
|
||||
return {
|
||||
uiTabsByWorkspace:
|
||||
@@ -184,13 +204,17 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
nextOrder === currentOrder
|
||||
? state.tabOrderByWorkspace
|
||||
: { ...state.tabOrderByWorkspace, [key]: nextOrder },
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[key]: tabId,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return resolvedTabId;
|
||||
},
|
||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||
const tabId = get().ensureTab({ serverId, workspaceId, target });
|
||||
if (!tabId) {
|
||||
return null;
|
||||
}
|
||||
get().focusTab({ serverId, workspaceId, tabId });
|
||||
return tabId;
|
||||
},
|
||||
focusTab: ({ serverId, workspaceId, tabId }) => {
|
||||
@@ -272,81 +296,40 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
};
|
||||
});
|
||||
},
|
||||
promoteDraftToAgent: ({ serverId, workspaceId, draftTabId, agentId }) => {
|
||||
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedDraftTabId = trimNonEmpty(draftTabId);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
if (!key || !normalizedDraftTabId || !normalizedAgentId) {
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
const normalizedTarget = normalizeTabTarget(target);
|
||||
if (!key || !normalizedTabId || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextTabId = `agent_${normalizedAgentId}`;
|
||||
let promotedTabId: string | null = null;
|
||||
let retargetedTabId: string | null = null;
|
||||
|
||||
set((state) => {
|
||||
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
const hasDraftInOrder = currentOrder.includes(normalizedDraftTabId);
|
||||
const hasDraftInUiTabs = currentTabs.some((tab) => tab.tabId === normalizedDraftTabId);
|
||||
if (!hasDraftInOrder && !hasDraftInUiTabs) {
|
||||
const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId);
|
||||
if (index < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const hasAgentTabInOrder = currentOrder.includes(nextTabId);
|
||||
const nextOrder = hasAgentTabInOrder
|
||||
? currentOrder.filter((tabId) => tabId !== normalizedDraftTabId)
|
||||
: currentOrder
|
||||
.map((tabId) => (tabId === normalizedDraftTabId ? nextTabId : tabId))
|
||||
.filter((tabId, index, arr) => arr.indexOf(tabId) === index);
|
||||
|
||||
const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedDraftTabId);
|
||||
const nextUiTabsByWorkspace =
|
||||
nextTabs.length === 0
|
||||
? (() => {
|
||||
const { [key]: _removed, ...rest } = state.uiTabsByWorkspace;
|
||||
return rest;
|
||||
})()
|
||||
: nextTabs.length === currentTabs.length
|
||||
? state.uiTabsByWorkspace
|
||||
: { ...state.uiTabsByWorkspace, [key]: nextTabs };
|
||||
|
||||
const nextTabOrderByWorkspace =
|
||||
nextOrder.length === 0
|
||||
? (() => {
|
||||
const { [key]: _removed, ...rest } = state.tabOrderByWorkspace;
|
||||
return rest;
|
||||
})()
|
||||
: nextOrder.length === currentOrder.length &&
|
||||
nextOrder.every((tabId, index) => tabId === currentOrder[index])
|
||||
? state.tabOrderByWorkspace
|
||||
: { ...state.tabOrderByWorkspace, [key]: nextOrder };
|
||||
|
||||
const currentFocused = trimNonEmpty(state.focusedTabIdByWorkspace[key]);
|
||||
const nextFocused = !currentFocused || currentFocused === normalizedDraftTabId ? nextTabId : currentFocused;
|
||||
const nextFocusedByWorkspace =
|
||||
nextFocused === currentFocused
|
||||
? state.focusedTabIdByWorkspace
|
||||
: { ...state.focusedTabIdByWorkspace, [key]: nextFocused };
|
||||
|
||||
const tabsChanged = nextTabs.length !== currentTabs.length;
|
||||
const orderChanged =
|
||||
nextOrder.length !== currentOrder.length ||
|
||||
nextOrder.some((tabId, index) => tabId !== currentOrder[index]);
|
||||
const focusChanged = nextFocused !== currentFocused;
|
||||
|
||||
if (!tabsChanged && !orderChanged && !focusChanged) {
|
||||
const currentTarget = currentTabs[index]?.target;
|
||||
if (currentTarget && tabTargetsEqual(currentTarget, normalizedTarget)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
promotedTabId = nextTabId;
|
||||
const nextTabs = currentTabs.map((tab, tabIndex) =>
|
||||
tabIndex === index ? { ...tab, target: normalizedTarget } : tab
|
||||
);
|
||||
retargetedTabId = normalizedTabId;
|
||||
return {
|
||||
uiTabsByWorkspace: nextUiTabsByWorkspace,
|
||||
tabOrderByWorkspace: nextTabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: nextFocusedByWorkspace,
|
||||
uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs },
|
||||
tabOrderByWorkspace: state.tabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: state.focusedTabIdByWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
return promotedTabId;
|
||||
return retargetedTabId;
|
||||
},
|
||||
reorderTabs: ({ serverId, workspaceId, tabIds }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
@@ -389,12 +372,25 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
}),
|
||||
{
|
||||
name: "workspace-tabs-state",
|
||||
version: 4,
|
||||
version: 5,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => {
|
||||
const nextUiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||
for (const key in state.uiTabsByWorkspace) {
|
||||
const tabs = (state.uiTabsByWorkspace[key] ?? []).filter((tab) => isUiTarget(tab.target));
|
||||
const tabs = (state.uiTabsByWorkspace[key] ?? [])
|
||||
.map((tab) => {
|
||||
const normalizedTarget = normalizeTabTarget(tab.target);
|
||||
const normalizedTabId = trimNonEmpty(tab.tabId);
|
||||
if (!normalizedTarget || !normalizedTabId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId: normalizedTabId,
|
||||
target: normalizedTarget,
|
||||
createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(),
|
||||
} satisfies WorkspaceTab;
|
||||
})
|
||||
.filter((tab): tab is WorkspaceTab => tab !== null);
|
||||
if (tabs.length > 0) {
|
||||
nextUiTabsByWorkspace[key] = tabs;
|
||||
}
|
||||
@@ -473,10 +469,6 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
orderFromTabs.push(tabId);
|
||||
}
|
||||
|
||||
if (!isUiTarget(normalizedTarget)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextUiTabs.push({
|
||||
tabId,
|
||||
target: normalizedTarget,
|
||||
|
||||
@@ -110,6 +110,7 @@ const lightSemanticColors = {
|
||||
surface1: "#fafafa", // Subtle hover (was zinc-100, now zinc-50)
|
||||
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
|
||||
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
|
||||
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
|
||||
|
||||
// Text
|
||||
foreground: "#09090b",
|
||||
@@ -179,6 +180,7 @@ const darkSemanticColors = {
|
||||
surface1: "#1f1f23", // Subtle hover
|
||||
surface2: "#27272a", // Elevated: badges, inputs, sheets
|
||||
surface3: "#3f3f46", // Highest elevation
|
||||
surfaceSidebar: "#121216", // Sidebar background (darker than main)
|
||||
|
||||
// Text
|
||||
foreground: "#fafafa",
|
||||
|
||||
Reference in New Issue
Block a user