Polish workspace header and unify workspace tab creation

This commit is contained in:
Mohamed Boudra
2026-03-01 20:45:16 +07:00
parent ac1106b259
commit c7cbf89f92
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
ActivityIndicator,
BackHandler, BackHandler,
Platform, Platform,
Pressable, Pressable,
@@ -10,13 +9,14 @@ import {
} from "react-native"; } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { ChevronDown, Plus, Terminal } from "lucide-react-native"; import { Bot, ChevronDown, Plus, Terminal } from "lucide-react-native";
import { GestureDetector } from "react-native-gesture-handler"; import { GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenHeader } from "@/components/headers/screen-header";
import { Combobox } from "@/components/ui/combobox"; import { Combobox } from "@/components/ui/combobox";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { TerminalPane } from "@/components/terminal-pane"; import { TerminalPane } from "@/components/terminal-pane";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
@@ -38,13 +38,15 @@ import {
checkoutStatusQueryKey, checkoutStatusQueryKey,
type CheckoutStatusPayload, type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query"; } from "@/hooks/use-checkout-status-query";
import { deriveBranchLabel } from "@/utils/agent-display-info";
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
import type { ListTerminalsResponse } from "@server/shared/messages"; import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { upsertTerminalListEntry } from "@/utils/terminal-list";
const TERMINALS_QUERY_STALE_TIME = 5_000; const TERMINALS_QUERY_STALE_TIME = 5_000;
const CREATE_TERMINAL_SWITCHER_ID = "__create_terminal__"; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
type TabAvailability = "available" | "invalid" | "unknown";
type RouteTabTarget = WorkspaceTabTarget | null; type RouteTabTarget = WorkspaceTabTarget | null;
@@ -59,6 +61,7 @@ type WorkspaceTabDescriptor =
key: string; key: string;
kind: "agent"; kind: "agent";
agentId: string; agentId: string;
provider: Agent["provider"];
label: string; label: string;
subtitle: string; subtitle: string;
} }
@@ -93,6 +96,35 @@ function deriveWorkspaceName(workspaceId: string): string {
return last ?? workspaceId; return last ?? workspaceId;
} }
function deriveWorkspaceHeaderTitle(input: {
workspaceName: string;
checkout: CheckoutStatusPayload | null;
}): string {
if (!input.checkout?.isGit) {
return input.workspaceName;
}
const branch = trimNonEmpty(input.checkout.currentBranch ?? null);
if (!branch || branch === "HEAD") {
return input.workspaceName;
}
return branch;
}
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 normalizeWorkspaceTab( function normalizeWorkspaceTab(
value: WorkspaceTabTarget | null | undefined value: WorkspaceTabTarget | null | undefined
): WorkspaceTabTarget | null { ): WorkspaceTabTarget | null {
@@ -151,15 +183,23 @@ function buildTabRoute(input: {
); );
} }
function isTabAvailable(input: { function resolveTabAvailability(input: {
tab: WorkspaceTabTarget; tab: WorkspaceTabTarget;
agentsHydrated: boolean;
terminalsHydrated: boolean;
agentsById: Map<string, Agent>; agentsById: Map<string, Agent>;
terminalIds: Set<string>; terminalIds: Set<string>;
}): boolean { }): TabAvailability {
if (input.tab.kind === "agent") { if (input.tab.kind === "agent") {
return input.agentsById.has(input.tab.agentId); if (!input.agentsHydrated) {
return "unknown";
}
return input.agentsById.has(input.tab.agentId) ? "available" : "invalid";
} }
return input.terminalIds.has(input.tab.terminalId); if (!input.terminalsHydrated) {
return "unknown";
}
return input.terminalIds.has(input.tab.terminalId) ? "available" : "invalid";
} }
function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] { function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] {
@@ -330,13 +370,20 @@ function WorkspaceScreenContent({
() => deriveWorkspaceName(normalizedWorkspaceId), () => deriveWorkspaceName(normalizedWorkspaceId),
[normalizedWorkspaceId] [normalizedWorkspaceId]
); );
const branchLabel = useMemo( const headerTitle = useMemo(
() => deriveBranchLabel(checkoutQuery.data ?? null), () =>
[checkoutQuery.data] deriveWorkspaceHeaderTitle({
workspaceName,
checkout: checkoutQuery.data ?? null,
}),
[checkoutQuery.data, workspaceName]
); );
const headerTitle = branchLabel ?? workspaceName;
const isGitCheckout = checkoutQuery.data?.isGit ?? false; 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 mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore( const desktopFileExplorerOpen = usePanelStore(
@@ -411,8 +458,9 @@ function WorkspaceScreenContent({
key: `agent:${agent.id}`, key: `agent:${agent.id}`,
kind: "agent", kind: "agent",
agentId: agent.id, agentId: agent.id,
provider: agent.provider,
label: agent.title?.trim() || "New agent", label: agent.title?.trim() || "New agent",
subtitle: "Agent", subtitle: `${formatProviderLabel(agent.provider)} agent`,
}); });
} }
@@ -475,31 +523,75 @@ function WorkspaceScreenContent({
return { kind: "terminal", terminalId: first.terminalId }; return { kind: "terminal", terminalId: first.terminalId };
}, [tabs]); }, [tabs]);
const requestedTabAvailability = useMemo<TabAvailability | null>(() => {
if (!requestedTab) {
return null;
}
return resolveTabAvailability({
tab: requestedTab,
agentsHydrated: areWorkspaceAgentsHydrated,
terminalsHydrated: areWorkspaceTerminalsHydrated,
agentsById,
terminalIds,
});
}, [
agentsById,
areWorkspaceAgentsHydrated,
areWorkspaceTerminalsHydrated,
requestedTab,
terminalIds,
]);
const storedTabAvailability = useMemo<TabAvailability | null>(() => {
if (!storedTab) {
return null;
}
return resolveTabAvailability({
tab: storedTab,
agentsHydrated: areWorkspaceAgentsHydrated,
terminalsHydrated: areWorkspaceTerminalsHydrated,
agentsById,
terminalIds,
});
}, [
agentsById,
areWorkspaceAgentsHydrated,
areWorkspaceTerminalsHydrated,
storedTab,
terminalIds,
]);
const resolvedTab = useMemo<WorkspaceTabTarget | null>(() => { const resolvedTab = useMemo<WorkspaceTabTarget | null>(() => {
if ( if (requestedTab && requestedTabAvailability !== "invalid") {
requestedTab &&
isTabAvailable({
tab: requestedTab,
agentsById,
terminalIds,
})
) {
return requestedTab; return requestedTab;
} }
if ( if (storedTab && storedTabAvailability !== "invalid") {
storedTab &&
isTabAvailable({
tab: storedTab,
agentsById,
terminalIds,
})
) {
return storedTab; return storedTab;
} }
return fallbackTab; return fallbackTab;
}, [agentsById, fallbackTab, requestedTab, storedTab, terminalIds]); }, [fallbackTab, requestedTab, requestedTabAvailability, storedTab, storedTabAvailability]);
const resolvedTabAvailability = useMemo<TabAvailability | null>(() => {
if (!resolvedTab) {
return null;
}
return resolveTabAvailability({
tab: resolvedTab,
agentsHydrated: areWorkspaceAgentsHydrated,
terminalsHydrated: areWorkspaceTerminalsHydrated,
agentsById,
terminalIds,
});
}, [
agentsById,
areWorkspaceAgentsHydrated,
areWorkspaceTerminalsHydrated,
resolvedTab,
terminalIds,
]);
const navigateToTab = useCallback( const navigateToTab = useCallback(
(tab: WorkspaceTabTarget) => { (tab: WorkspaceTabTarget) => {
@@ -531,6 +623,9 @@ function WorkspaceScreenContent({
if (!resolvedTab) { if (!resolvedTab) {
return; return;
} }
if (resolvedTabAvailability !== "available") {
return;
}
setLastFocusedTab({ setLastFocusedTab({
serverId: normalizedServerId, serverId: normalizedServerId,
@@ -541,6 +636,7 @@ function WorkspaceScreenContent({
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
resolvedTab, resolvedTab,
resolvedTabAvailability,
setLastFocusedTab, setLastFocusedTab,
]); ]);
@@ -569,7 +665,9 @@ function WorkspaceScreenContent({
]); ]);
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false); const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
const [isNewTabMenuOpen, setIsNewTabMenuOpen] = useState(false);
const tabSwitcherAnchorRef = useRef<View>(null); const tabSwitcherAnchorRef = useRef<View>(null);
const newTabAnchorRef = useRef<View>(null);
const tabByKey = useMemo(() => { const tabByKey = useMemo(() => {
const map = new Map<string, WorkspaceTabTarget>(); const map = new Map<string, WorkspaceTabTarget>();
@@ -595,21 +693,30 @@ function WorkspaceScreenContent({
const tabSwitcherOptions = useMemo( const tabSwitcherOptions = useMemo(
() => () =>
[ tabs.map((tab) => ({
...tabs.map((tab) => ({ id: tab.key,
id: tab.key, label: tab.label,
label: tab.label, description: tab.subtitle,
description: tab.subtitle, })),
})),
{
id: CREATE_TERMINAL_SWITCHER_ID,
label: "New terminal",
description: "Create",
},
],
[tabs] [tabs]
); );
const newTabOptions = useMemo(
() => [
{
id: NEW_TAB_AGENT_OPTION_ID,
label: "Agent tab",
description: "Open the draft agent flow for this workspace",
},
{
id: NEW_TAB_TERMINAL_OPTION_ID,
label: "Terminal tab",
description: "Create a new terminal in this workspace",
},
],
[]
);
const activeTabLabel = useMemo(() => { const activeTabLabel = useMemo(() => {
const active = tabs.find((tab) => tab.key === activeTabKey); const active = tabs.find((tab) => tab.key === activeTabKey);
return active?.label ?? "Select tab"; return active?.label ?? "Select tab";
@@ -625,6 +732,9 @@ function WorkspaceScreenContent({
}, [normalizedServerId, normalizedWorkspaceId, router]); }, [normalizedServerId, normalizedWorkspaceId, router]);
const handleCreateTerminal = useCallback(() => { const handleCreateTerminal = useCallback(() => {
if (createTerminalMutation.isPending) {
return;
}
if (!normalizedWorkspaceId.startsWith("/")) { if (!normalizedWorkspaceId.startsWith("/")) {
return; return;
} }
@@ -633,11 +743,6 @@ function WorkspaceScreenContent({
const handleSelectSwitcherTab = useCallback( const handleSelectSwitcherTab = useCallback(
(key: string) => { (key: string) => {
if (key === CREATE_TERMINAL_SWITCHER_ID) {
setIsTabSwitcherOpen(false);
handleCreateTerminal();
return;
}
const tab = tabByKey.get(key); const tab = tabByKey.get(key);
if (!tab) { if (!tab) {
return; return;
@@ -645,7 +750,21 @@ function WorkspaceScreenContent({
setIsTabSwitcherOpen(false); setIsTabSwitcherOpen(false);
navigateToTab(tab); navigateToTab(tab);
}, },
[handleCreateTerminal, navigateToTab, tabByKey] [navigateToTab, tabByKey]
);
const handleSelectNewTabOption = useCallback(
(key: string) => {
setIsNewTabMenuOpen(false);
if (key === NEW_TAB_AGENT_OPTION_ID) {
handleCreateAgent();
return;
}
if (key === NEW_TAB_TERMINAL_OPTION_ID) {
handleCreateTerminal();
}
},
[handleCreateAgent, handleCreateTerminal]
); );
const renderContent = () => { const renderContent = () => {
@@ -653,31 +772,8 @@ function WorkspaceScreenContent({
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={styles.emptyStateText}> <Text style={styles.emptyStateText}>
No agent or terminal tabs are available for this workspace. No tabs are available yet. Use New tab to create an agent or terminal.
</Text> </Text>
<Pressable
style={({ hovered, pressed }) => [
styles.emptyStateButton,
(hovered || pressed) && styles.emptyStateButtonActive,
]}
onPress={handleCreateAgent}
>
<Text style={styles.emptyStateButtonText}>Create agent</Text>
</Pressable>
<Pressable
style={({ hovered, pressed }) => [
styles.emptyStateButton,
(hovered || pressed) && styles.emptyStateButtonActive,
]}
onPress={handleCreateTerminal}
disabled={createTerminalMutation.isPending}
>
{createTerminalMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<Text style={styles.emptyStateButtonText}>Create terminal</Text>
)}
</Pressable>
</View> </View>
); );
} }
@@ -741,16 +837,19 @@ function WorkspaceScreenContent({
) : null} ) : null}
<Pressable <Pressable
testID="workspace-header-plus" ref={newTabAnchorRef}
testID="workspace-new-tab"
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.plusButton, styles.newTabButton,
(hovered || pressed) && styles.plusButtonActive, (hovered || pressed) && styles.newTabButtonActive,
]} ]}
onPress={handleCreateAgent} onPress={() => setIsNewTabMenuOpen(true)}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Create agent" accessibilityLabel="New tab"
> >
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.newTabButtonText}>New tab</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
{isMobile ? ( {isMobile ? (
@@ -766,6 +865,18 @@ function WorkspaceScreenContent({
anchorRef={tabSwitcherAnchorRef} anchorRef={tabSwitcherAnchorRef}
/> />
) : null} ) : null}
<Combobox
options={newTabOptions}
value=""
onSelect={handleSelectNewTabOption}
searchable={false}
title="New tab"
searchPlaceholder="Search tab types"
open={isNewTabMenuOpen}
onOpenChange={setIsNewTabMenuOpen}
anchorRef={newTabAnchorRef}
/>
</View> </View>
} }
/> />
@@ -780,14 +891,20 @@ function WorkspaceScreenContent({
> >
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = tab.key === activeTabKey; const isActive = tab.key === activeTabKey;
const iconColor = isActive
? theme.colors.foreground
: theme.colors.foregroundMuted;
const icon = const icon =
tab.kind === "agent" ? ( tab.kind === "agent" ? (
<AgentStatusDot tab.provider === "claude" ? (
status={agentsById.get(tab.agentId)?.status ?? "idle"} <ClaudeIcon size={14} color={iconColor} />
requiresAttention={agentsById.get(tab.agentId)?.requiresAttention ?? false} ) : tab.provider === "codex" ? (
/> <CodexIcon size={14} color={iconColor} />
) : (
<Bot size={14} color={iconColor} />
)
) : ( ) : (
<Terminal size={14} color={theme.colors.foregroundMuted} /> <Terminal size={14} color={iconColor} />
); );
return ( return (
@@ -820,28 +937,6 @@ function WorkspaceScreenContent({
); );
})} })}
</ScrollView> </ScrollView>
<Pressable
testID="workspace-create-terminal"
accessibilityRole="button"
accessibilityLabel="Create terminal"
onPress={handleCreateTerminal}
disabled={createTerminalMutation.isPending}
style={({ hovered, pressed }) => [
styles.createTerminalButton,
(hovered || pressed) && styles.createTerminalButtonActive,
]}
>
{createTerminalMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.createTerminalIcon}>
<Terminal size={16} color={theme.colors.foregroundMuted} />
<View style={styles.createTerminalPlusIcon}>
<Plus size={10} color={theme.colors.foregroundMuted} />
</View>
</View>
)}
</Pressable>
</View> </View>
) : null} ) : null}
@@ -884,16 +979,23 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
plusButton: { newTabButton: {
padding: { flexDirection: "row",
xs: theme.spacing[3], alignItems: "center",
md: theme.spacing[2], gap: theme.spacing[1],
}, paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg, paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
}, },
plusButtonActive: { newTabButtonActive: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
newTabButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
switcherTrigger: { switcherTrigger: {
maxWidth: 220, maxWidth: 220,
flexDirection: "row", flexDirection: "row",
@@ -918,8 +1020,6 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
flexDirection: "row",
alignItems: "center",
}, },
tabsScroll: { tabsScroll: {
flex: 1, flex: 1,
@@ -962,25 +1062,6 @@ const styles = StyleSheet.create((theme) => ({
tabLabelActive: { tabLabelActive: {
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
createTerminalButton: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
marginRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
createTerminalButtonActive: {
backgroundColor: theme.colors.surface2,
},
createTerminalIcon: {
position: "relative",
justifyContent: "center",
alignItems: "center",
},
createTerminalPlusIcon: {
position: "absolute",
top: -6,
right: -6,
},
content: { content: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
@@ -996,18 +1077,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
textAlign: "center", textAlign: "center",
}, },
emptyStateButton: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
},
emptyStateButtonActive: {
backgroundColor: theme.colors.surface2,
},
emptyStateButtonText: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
})); }));