feat: projects → workspaces → tabs

This commit is contained in:
Mohamed Boudra
2026-03-01 18:22:11 +07:00
parent 2e201cab09
commit 080fb74642
43 changed files with 4054 additions and 2654 deletions
@@ -105,9 +105,15 @@ function logAgentExplorer(event: string, details: Record<string, unknown>): void
export function AgentReadyScreen({
serverId,
agentId,
showHeader = true,
showExplorerSidebar = true,
wrapWithExplorerSidebarProvider = true,
}: {
serverId: string;
agentId: string;
showHeader?: boolean;
showExplorerSidebar?: boolean;
wrapWithExplorerSidebarProvider?: boolean;
}) {
const router = useRouter();
const resolvedAgentId = agentId?.trim() || undefined;
@@ -193,17 +199,23 @@ export function AgentReadyScreen({
);
}
return (
<ExplorerSidebarAnimationProvider>
<AgentScreenContent
serverId={resolvedServerId}
agentId={resolvedAgentId}
client={runtimeClient}
isConnected={runtimeIsConnected}
connectionStatus={connectionStatus}
/>
</ExplorerSidebarAnimationProvider>
const content = (
<AgentScreenContent
serverId={resolvedServerId}
agentId={resolvedAgentId}
client={runtimeClient}
isConnected={runtimeIsConnected}
connectionStatus={connectionStatus}
showHeader={showHeader}
showExplorerSidebar={showExplorerSidebar}
/>
);
if (!wrapWithExplorerSidebarProvider) {
return content;
}
return <ExplorerSidebarAnimationProvider>{content}</ExplorerSidebarAnimationProvider>;
}
type AgentScreenContentProps = {
@@ -212,6 +224,8 @@ type AgentScreenContentProps = {
client: DaemonClient;
isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus;
showHeader: boolean;
showExplorerSidebar: boolean;
};
function toErrorMessage(error: unknown): string {
@@ -231,6 +245,8 @@ function AgentScreenContent({
client,
isConnected,
connectionStatus,
showHeader,
showExplorerSidebar,
}: AgentScreenContentProps) {
const { theme } = useUnistyles();
const toast = useToast();
@@ -990,7 +1006,7 @@ function AgentScreenContent({
if (viewState.tag === "not_found") {
return (
<View style={styles.container} testID="agent-not-found">
<MenuHeader title="Agent" />
{showHeader ? <MenuHeader title="Agent" /> : null}
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
@@ -1001,7 +1017,7 @@ function AgentScreenContent({
if (viewState.tag === "error") {
return (
<View style={styles.container} testID="agent-load-error">
<MenuHeader title="Agent" />
{showHeader ? <MenuHeader title="Agent" /> : null}
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Failed to load agent</Text>
<Text style={styles.statusText}>{viewState.message}</Text>
@@ -1013,7 +1029,7 @@ function AgentScreenContent({
if (viewState.tag === "boot" || !effectiveAgent) {
return (
<View style={styles.container} testID="agent-loading">
<MenuHeader title="Agent" />
{showHeader ? <MenuHeader title="Agent" /> : null}
<View style={styles.errorContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
@@ -1029,80 +1045,81 @@ function AgentScreenContent({
>
<View style={styles.container}>
{/* Header */}
<MenuHeader
title={effectiveAgent.title || "Agent"}
rightContent={
<View style={styles.headerRightContent}>
<HeaderToggleButton
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.menuButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{isMobile ? (
checkout?.isGit ? (
<GitBranch
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
{showHeader ? (
<MenuHeader
title={effectiveAgent.title || "Agent"}
rightContent={
<View style={styles.headerRightContent}>
<HeaderToggleButton
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.menuButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{isMobile ? (
checkout?.isGit ? (
<GitBranch
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
) : (
<Folder
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)
) : (
<Folder
size={theme.iconSize.lg}
<PanelRight
size={theme.iconSize.md}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)
) : (
<PanelRight
size={theme.iconSize.md}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
)}
</HeaderToggleButton>
<DropdownMenu
onOpenChange={(open) => {
if (open && agent?.cwd) {
checkoutStatusQuery.refresh().catch(() => {});
}
/>
)}
</HeaderToggleButton>
<DropdownMenu
onOpenChange={(open) => {
if (open && agent?.cwd) {
checkoutStatusQuery.refresh().catch(() => {});
}
}}
>
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
<MoreVertical
size={isMobile ? theme.iconSize.lg : theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer} testID="agent-details-sheet">
<Pressable
testID="agent-details-agent-id"
style={({ hovered, pressed }) => [
styles.menuMetaRow,
(hovered || pressed) && styles.menuMetaRowActive,
]}
onPress={() => {
void handleCopyMeta("Directory", effectiveAgent.cwd);
}}
>
<Text style={styles.menuMetaLabel} numberOfLines={1}>
Directory
</Text>
}}
>
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
<MoreVertical
size={isMobile ? theme.iconSize.lg : theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer} testID="agent-details-sheet">
<Pressable
testID="agent-details-agent-id"
style={({ hovered, pressed }) => [
styles.menuMetaRow,
(hovered || pressed) && styles.menuMetaRowActive,
]}
onPress={() => {
void handleCopyMeta("Directory", effectiveAgent.cwd);
}}
>
<Text style={styles.menuMetaLabel} numberOfLines={1}>
Directory
</Text>
<Text
testID="agent-details-agent-id-value"
style={styles.menuMetaValue}
@@ -1224,11 +1241,12 @@ function AgentScreenContent({
>
{isInitializing ? "Refreshing..." : "Refresh"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
}
/>
</DropdownMenuContent>
</DropdownMenu>
</View>
}
/>
) : null}
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
@@ -1272,14 +1290,14 @@ function AgentScreenContent({
</FileDropZone>
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{!isMobile && isExplorerOpen && resolvedAgentId && (
{showExplorerSidebar && !isMobile && isExplorerOpen && resolvedAgentId ? (
<ExplorerSidebar
serverId={serverId}
agentId={resolvedAgentId}
cwd={effectiveAgent.cwd}
workspaceId={effectiveAgent.cwd}
workspaceRoot={effectiveAgent.cwd}
isGit={isGitCheckout}
/>
)}
) : null}
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
@@ -1304,14 +1322,14 @@ function AgentScreenContent({
)}
{/* Mobile Explorer Sidebar Overlay */}
{isMobile && resolvedAgentId && (
{showExplorerSidebar && isMobile && resolvedAgentId ? (
<ExplorerSidebar
serverId={serverId}
agentId={resolvedAgentId}
cwd={effectiveAgent.cwd}
workspaceId={effectiveAgent.cwd}
workspaceRoot={effectiveAgent.cwd}
isGit={isGitCheckout}
/>
)}
) : null}
</>
);
}
@@ -1342,8 +1342,8 @@ function DraftAgentScreenContent({
{!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? (
<ExplorerSidebar
serverId={explorerServerId}
agentId={DRAFT_AGENT_ID}
cwd={draftExplorerCheckout.cwd}
workspaceId={draftExplorerCheckout.cwd}
workspaceRoot={draftExplorerCheckout.cwd}
isGit={explorerIsGit}
/>
) : null}
@@ -1365,8 +1365,8 @@ function DraftAgentScreenContent({
{isMobile && explorerServerId && draftExplorerCheckout ? (
<ExplorerSidebar
serverId={explorerServerId}
agentId={DRAFT_AGENT_ID}
cwd={draftExplorerCheckout.cwd}
workspaceId={draftExplorerCheckout.cwd}
workspaceRoot={draftExplorerCheckout.cwd}
isGit={explorerIsGit}
/>
) : null}
@@ -1,206 +0,0 @@
import { useCallback, useEffect, useMemo } from "react";
import type { ReactNode } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { useSessionDirectory } from "@/hooks/use-session-directory";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import type { Agent } from "@/contexts/session-context";
import {
buildHostAgentDetailRoute,
buildHostAgentDraftRoute,
} from "@/utils/host-routes";
type AgentMatch = {
serverId: string;
serverLabel: string;
agent: Agent;
};
export function LegacyAgentIdScreen({ agentId }: { agentId: string }) {
const router = useRouter();
const { theme } = useUnistyles();
const { daemons } = useDaemonRegistry();
const sessionDirectory = useSessionDirectory();
const resolvedAgentId = typeof agentId === "string" ? agentId.trim() : undefined;
const matches = useMemo<AgentMatch[]>(() => {
if (!resolvedAgentId) {
return [];
}
const results: AgentMatch[] = [];
sessionDirectory.forEach((session, serverId) => {
if (!session) {
return;
}
const agent = session.agents.get(resolvedAgentId);
if (!agent) {
return;
}
const serverLabel =
daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
results.push({ serverId, serverLabel, agent });
});
return results;
}, [daemons, resolvedAgentId, sessionDirectory]);
const hasSessions = sessionDirectory.size > 0;
const isRedirecting = Boolean(resolvedAgentId && matches.length === 1);
useEffect(() => {
if (!isRedirecting) {
return;
}
const match = matches[0];
router.replace(buildHostAgentDetailRoute(match.serverId, match.agent.id) as any);
}, [isRedirecting, matches, router]);
const handleGoDraft = useCallback(() => {
const firstMatchServerId = matches[0]?.serverId ?? null;
if (firstMatchServerId) {
router.replace(buildHostAgentDraftRoute(firstMatchServerId) as any);
return;
}
router.replace("/" as any);
}, [matches, router]);
const handleSelectMatch = useCallback(
(match: AgentMatch) => {
router.replace(buildHostAgentDetailRoute(match.serverId, match.agent.id) as any);
},
[router]
);
let body: ReactNode = null;
if (!resolvedAgentId) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Missing agent</Text>
<Text style={styles.subtitle}>
This link is missing an agent id. Go back to the Agents screen to pick one.
</Text>
<Pressable style={styles.primaryButton} onPress={handleGoDraft}>
<Text style={styles.primaryButtonText}>New agent</Text>
</Pressable>
</View>
);
} else if (!hasSessions || isRedirecting) {
body = (
<View style={styles.centerState}>
<ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={styles.subtitle}>
{isRedirecting ? "Opening your agent..." : "Looking for your agent..."}
</Text>
</View>
);
} else if (matches.length === 0) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Agent not found</Text>
<Text style={styles.subtitle}>
We could not find {resolvedAgentId} on any host right now. Hosts reconnect automatically—open it
again from the Agents screen after it comes back online.
</Text>
<Pressable style={styles.primaryButton} onPress={handleGoDraft}>
<Text style={styles.primaryButtonText}>New agent</Text>
</Pressable>
</View>
);
} else if (matches.length > 1) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Pick a host</Text>
<Text style={styles.subtitle}>
Multiple hosts have an agent with this id. Choose the one you intended to open.
</Text>
<View style={styles.matchList}>
{matches.map((match) => (
<Pressable
key={`${match.serverId}:${match.agent.id}`}
style={styles.matchButton}
onPress={() => handleSelectMatch(match)}
>
<Text style={styles.matchLabel}>{match.serverLabel}</Text>
<Text style={styles.matchMeta} numberOfLines={1}>
{match.agent.cwd}
</Text>
</Pressable>
))}
</View>
</View>
);
}
return (
<View style={styles.container}>
<BackHeader title="Agent" onBack={handleGoDraft} />
<View style={styles.content}>{body}</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
content: {
flex: 1,
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[8],
},
centerState: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[4],
},
title: {
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
subtitle: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
primaryButton: {
marginTop: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.primary,
},
primaryButtonText: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
matchList: {
width: "100%",
gap: theme.spacing[3],
},
matchButton: {
width: "100%",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
matchLabel: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
},
matchMeta: {
marginTop: theme.spacing[1],
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}));