refactor(app): extract agent panel content

This commit is contained in:
Mohamed Boudra
2026-03-20 18:34:52 +07:00
parent 614c085310
commit 584f5ce05e
3 changed files with 969 additions and 1165 deletions
+930 -5
View File
@@ -1,12 +1,51 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, Platform, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Bot } from "lucide-react-native";
import invariant from "tiny-invariant";
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import { ToastViewport, useToastHost } from "@/components/toast-host";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import {
useAgentScreenStateMachine,
type AgentScreenMissingState,
} from "@/hooks/use-agent-screen-state-machine";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
useHostRuntimeSession,
useHosts,
type HostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import {
getInitDeferred,
getInitKey,
} from "@/utils/agent-initialization";
import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import {
deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
function formatProviderLabel(provider: Agent["provider"]): string {
if (provider === "claude") {
@@ -66,11 +105,9 @@ function AgentPanel() {
const { serverId, target, openFileInWorkspace } = usePaneContext();
invariant(target.kind === "agent", "AgentPanel requires agent target");
return (
<AgentReadyScreen
<AgentPanelContent
serverId={serverId}
agentId={target.agentId}
showExplorerSidebar={false}
wrapWithExplorerSidebarProvider={false}
onOpenWorkspaceFile={({ filePath }) => {
openFileInWorkspace(filePath);
}}
@@ -83,3 +120,891 @@ export const agentPanelRegistration: PanelRegistration<"agent"> = {
component: AgentPanel,
useDescriptor: useAgentPanelDescriptor,
};
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
function logWebStickyBottom(
_event: string,
_details: Record<string, unknown>
): void {}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function isNotFoundErrorMessage(message: string): boolean {
return /agent not found|not found/i.test(message);
}
function AgentPanelContent({
serverId,
agentId,
onOpenWorkspaceFile,
}: {
serverId: string;
agentId: string;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) {
const resolvedAgentId = agentId.trim() || undefined;
const resolvedServerId = serverId.trim() || undefined;
const daemons = useHosts();
const runtimeServerId = resolvedServerId ?? "";
const {
snapshot: runtimeSnapshot,
client: runtimeClient,
isConnected: runtimeIsConnected,
} = useHostRuntimeSession(runtimeServerId);
const connectionServerId = resolvedServerId ?? null;
const daemon = connectionServerId
? daemons.find((entry) => entry.serverId === connectionServerId) ?? null
: null;
const serverLabel =
daemon?.label ?? connectionServerId ?? "Selected host";
const isUnknownDaemon = Boolean(connectionServerId && !daemon);
const connectionStatus: HostRuntimeConnectionStatus =
runtimeSnapshot?.connectionStatus ??
(isUnknownDaemon ? "offline" : "connecting");
const lastConnectionError = runtimeSnapshot?.lastError ?? null;
if (!resolvedServerId || !runtimeClient) {
return (
<AgentSessionUnavailableState
serverLabel={serverLabel}
connectionStatus={connectionStatus}
lastError={lastConnectionError}
isUnknownDaemon={isUnknownDaemon}
/>
);
}
return (
<AgentPanelBody
serverId={resolvedServerId}
agentId={resolvedAgentId}
client={runtimeClient}
isConnected={runtimeIsConnected}
connectionStatus={connectionStatus}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
function AgentPanelBody({
serverId,
agentId,
client,
isConnected,
connectionStatus,
onOpenWorkspaceFile,
}: {
serverId: string;
agentId?: string;
client: NonNullable<ReturnType<typeof useHostRuntimeSession>["client"]>;
isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) {
const { theme } = useUnistyles();
const panelToast = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(null);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const isScreenFocused = useIsFocused();
const reconnectToastArmedRef = useRef(false);
const initAttemptTokenRef = useRef(0);
const routeBottomAnchorRequestRef = useRef<{
routeKey: string;
reason: "initial-entry" | "resume";
} | null>(null);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback(
(addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
},
[]
);
const agent = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined
);
const streamItemsRaw = useSessionStore((state) =>
agentId
? state.sessions[serverId]?.agentStreamTail?.get(agentId)
: undefined
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const markPendingCreateLifecycle = useCreateFlowStore(
(state) => state.markLifecycle
);
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
const isInitializingFromMap = useSessionStore((state) =>
agentId
? state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false
: false
);
const historySyncGeneration = useSessionStore(
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0
);
const hasAppliedAuthoritativeHistory = useSessionStore((state) =>
agentId
? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) ===
true
: false
);
const agentHistorySyncGeneration = useSessionStore((state) =>
agentId
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1
: -1
);
const allPendingPermissions = useSessionStore(
(state) => state.sessions[serverId]?.pendingPermissions
);
const setAgents = useSessionStore((state) => state.setAgents);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setPendingPermissions = useSessionStore(
(state) => state.setPendingPermissions
);
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const { ensureAgentIsInitialized } = useAgentInitialization({
serverId,
client: hasSession ? client : null,
});
const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({
kind: "idle",
});
const pendingCreate = useMemo(() => {
if (!agentId) {
return null;
}
const values = Object.values(pendingByDraftId);
for (const entry of values) {
if (
entry.lifecycle === "active" &&
entry.serverId === serverId &&
entry.agentId === agentId
) {
return entry;
}
}
return null;
}, [agentId, pendingByDraftId, serverId]);
const isPendingCreateForPanel = Boolean(pendingCreate);
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
const pendingPermissions = useMemo(() => {
if (!allPendingPermissions || !agentId) {
return new Map<string, PendingPermission>();
}
const filtered = new Map<string, PendingPermission>();
for (const [key, permission] of allPendingPermissions) {
if (permission.agentId === agentId) {
filtered.set(key, permission);
}
}
return filtered;
}, [agentId, allPendingPermissions]);
const setFocusedAgentId = useCallback(
(focusedAgentId: string | null) => {
useSessionStore.getState().setFocusedAgentId(serverId, focusedAgentId);
},
[serverId]
);
const attentionController = useAgentAttentionClear({
agentId,
client,
isConnected,
requiresAttention: agent?.requiresAttention,
attentionReason: agent?.attentionReason,
isScreenFocused,
});
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate",
});
const handleHistorySyncFailure = useCallback(
({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => {
if (agentId) {
console.warn("[AgentPanel] history sync failed", {
origin,
agentId,
error,
});
}
const message = toErrorMessage(error);
setMissingAgentState((previous) => {
if (previous.kind === "error" && previous.message === message) {
return previous;
}
return { kind: "error", message };
});
},
[agentId]
);
const ensureInitializedWithSyncErrorHandling = useCallback(
(origin: "focus" | "entry") => {
if (!agentId) {
return;
}
ensureAgentIsInitialized(agentId).catch((error) => {
handleHistorySyncFailure({ origin, error });
});
},
[agentId, ensureAgentIsInitialized, handleHistorySyncFailure]
);
useEffect(() => {
if (connectionStatus === "online") {
reconnectToastArmedRef.current = false;
return;
}
if (connectionStatus === "idle") {
return;
}
if (!reconnectToastArmedRef.current) {
reconnectToastArmedRef.current = true;
panelToast.api.show("Reconnecting...", {
durationMs: 2200,
testID: "agent-reconnecting-toast",
});
}
}, [connectionStatus, panelToast.api]);
useFocusEffect(
useCallback(() => {
if (!agentId || !isConnected || !hasSession) {
return;
}
ensureInitializedWithSyncErrorHandling("focus");
}, [agentId, ensureInitializedWithSyncErrorHandling, hasSession, isConnected])
);
const isArchivingCurrentAgent = Boolean(
agentId &&
isArchivingAgent({ serverId, agentId })
);
useFocusEffect(
useCallback(() => {
if (!agentId) {
setFocusedAgentId(null);
return;
}
setFocusedAgentId(agentId);
return () => {
attentionController.clearOnAgentBlur();
setFocusedAgentId(null);
};
}, [agentId, attentionController, setFocusedAgentId])
);
const isInitializing = agentId ? isInitializingFromMap !== false : false;
const isHistorySyncing = useMemo(() => {
if (!agentId || !isInitializing) {
return false;
}
const initKey = getInitKey(serverId, agentId);
return Boolean(getInitDeferred(initKey));
}, [agentId, isInitializing, serverId]);
const needsAuthoritativeSync = useMemo(() => {
if (!agentId) {
return false;
}
return agentHistorySyncGeneration < historySyncGeneration;
}, [agentHistorySyncGeneration, agentId, historySyncGeneration]);
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (!isPendingCreateForPanel || !pendingCreate) {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: "user_message",
id: pendingCreate.clientMessageId,
text: pendingCreate.text,
timestamp: new Date(pendingCreate.timestamp),
...(pendingCreate.images && pendingCreate.images.length > 0
? { images: pendingCreate.images }
: {}),
},
];
}, [isPendingCreateForPanel, pendingCreate]);
const mergedStreamItems = useMemo<StreamItem[]>(() => {
if (optimisticStreamItems.length === 0) {
return streamItems;
}
const optimistic = optimisticStreamItems[0];
if (!optimistic) {
return streamItems;
}
const alreadyHasOptimistic = streamItems.some(
(item) => item.kind === "user_message" && item.id === optimistic.id
);
return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems];
}, [optimisticStreamItems, streamItems]);
const shouldUseOptimisticStream =
isPendingCreateForPanel && optimisticStreamItems.length > 0;
const authoritativeStatus = agent?.status;
const isAuthoritativeBootstrapping =
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
const showPendingCreateSubmitLoading =
isPendingCreateForPanel &&
(!authoritativeStatus || isAuthoritativeBootstrapping);
const canFinalizePendingCreate =
Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping;
const placeholderAgent: Agent | null = useMemo(() => {
if (!shouldUseOptimisticStream || !agentId) {
return null;
}
const now = new Date();
return {
serverId,
id: agentId,
provider: "claude",
status: "running",
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider: "claude",
sessionId: null,
model: null,
modeId: null,
},
title: "Agent",
cwd: ".",
model: null,
labels: {},
};
}, [agentId, serverId, shouldUseOptimisticStream]);
const viewState = useAgentScreenStateMachine({
routeKey: `${serverId}:${agentId ?? ""}`,
input: {
agent: agent ?? null,
placeholderAgent,
missingAgentState,
isConnected,
isArchivingCurrentAgent,
isHistorySyncing,
needsAuthoritativeSync,
shouldUseOptimisticStream,
hasHydratedHistoryBefore,
},
});
const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null;
const routeEntryKey = agentId ? `${serverId}:${agentId}` : null;
routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({
cachedIntent: routeBottomAnchorRequestRef.current,
routeKey: routeEntryKey,
hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory,
});
const routeBottomAnchorRequest = useMemo(
() =>
deriveRouteBottomAnchorRequest({
intent: routeBottomAnchorRequestRef.current,
effectiveAgentId: effectiveAgent?.id ?? null,
}),
[effectiveAgent?.id]
);
useEffect(() => {
if (!isPendingCreateForPanel || !pendingCreate) {
return;
}
const hasUserMessage = streamItems.some(
(item) =>
item.kind === "user_message" &&
item.id === pendingCreate.clientMessageId
);
if (hasUserMessage && canFinalizePendingCreate) {
if (
agentId &&
pendingCreate.images &&
pendingCreate.images.length > 0
) {
setAgentStreamTail(serverId, (previous) => {
const current = previous.get(agentId);
if (!current) {
return previous;
}
const merged = mergePendingCreateImages({
streamItems: current,
clientMessageId: pendingCreate.clientMessageId,
images: pendingCreate.images,
});
if (merged === current) {
return previous;
}
const next = new Map(previous);
next.set(agentId, merged);
return next;
});
}
markPendingCreateLifecycle({
draftId: pendingCreate.draftId,
lifecycle: "sent",
});
clearPendingCreate({ draftId: pendingCreate.draftId });
}
}, [
agentId,
canFinalizePendingCreate,
clearPendingCreate,
isPendingCreateForPanel,
markPendingCreateLifecycle,
pendingCreate,
serverId,
setAgentStreamTail,
streamItems,
]);
useEffect(() => {
if (!agentId) {
return;
}
if (!isConnected || !hasSession) {
return;
}
const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web";
if (!shouldSyncOnEntry) {
return;
}
ensureInitializedWithSyncErrorHandling("entry");
}, [
agentId,
ensureInitializedWithSyncErrorHandling,
hasSession,
isConnected,
needsAuthoritativeSync,
]);
useEffect(() => {
initAttemptTokenRef.current += 1;
setMissingAgentState({ kind: "idle" });
}, [agentId, serverId]);
useEffect(() => {
if (!agentId) {
return;
}
if (agent || shouldUseOptimisticStream) {
if (missingAgentState.kind !== "idle") {
setMissingAgentState({ kind: "idle" });
}
return;
}
if (!isConnected || !hasSession) {
return;
}
if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") {
return;
}
setMissingAgentState({ kind: "resolving" });
const attemptToken = ++initAttemptTokenRef.current;
ensureAgentIsInitialized(agentId)
.then(async () => {
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
const currentAgent = useSessionStore
.getState()
.sessions[serverId]
?.agents.get(agentId);
if (!currentAgent) {
const result = await client.fetchAgent(agentId);
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
if (!result) {
setMissingAgentState({
kind: "not_found",
message: `Agent not found: ${agentId}`,
});
return;
}
const normalized = normalizeAgentSnapshot(result.agent, serverId);
const hydrated = {
...normalized,
projectPlacement: result.project,
};
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(hydrated.id, hydrated);
return next;
});
setPendingPermissions(serverId, (previous) => {
const next = new Map(previous);
for (const [key, pending] of next.entries()) {
if (pending.agentId === hydrated.id) {
next.delete(key);
}
}
for (const request of hydrated.pendingPermissions) {
const key = derivePendingPermissionKey(hydrated.id, request);
next.set(key, { key, agentId: hydrated.id, request });
}
return next;
});
}
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
setMissingAgentState({ kind: "idle" });
})
.catch((error) => {
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
const message = toErrorMessage(error);
if (isNotFoundErrorMessage(message)) {
setMissingAgentState({ kind: "not_found", message });
return;
}
setMissingAgentState({ kind: "error", message });
});
}, [
agent,
agentId,
client,
ensureAgentIsInitialized,
hasSession,
isConnected,
missingAgentState.kind,
serverId,
setAgents,
setPendingPermissions,
shouldUseOptimisticStream,
]);
const isHistoryRefreshCatchingUp =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "toast";
const shouldEmitSyncErrorToast =
viewState.tag === "ready" &&
viewState.sync.status === "sync_error" &&
viewState.sync.shouldEmitSyncErrorToast;
useDelayedHistoryRefreshToast({
isCatchingUp: isHistoryRefreshCatchingUp,
indicatorColor: theme.colors.primary,
showToast: panelToast.api.show,
});
useEffect(() => {
if (!shouldEmitSyncErrorToast) {
return;
}
panelToast.api.error("Failed to refresh agent. Retrying in background.");
}, [panelToast.api, shouldEmitSyncErrorToast]);
if (viewState.tag === "not_found") {
return (
<View style={styles.container} testID="agent-not-found">
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
</View>
);
}
if (viewState.tag === "error") {
return (
<View style={styles.container} testID="agent-load-error">
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Failed to load agent</Text>
<Text style={styles.statusText}>{viewState.message}</Text>
</View>
</View>
);
}
if (viewState.tag === "boot" || !effectiveAgent) {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
</View>
);
}
return (
<View style={styles.root}>
<FileDropZone
onFilesDropped={handleFilesDropped}
disabled={isArchivingCurrentAgent}
>
<View style={styles.container}>
<View style={styles.contentContainer}>
<ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]}
>
<AgentStreamView
ref={streamViewRef}
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
streamItems={
shouldUseOptimisticStream ? mergedStreamItems : streamItems
}
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
</View>
{agentId && !isArchivingCurrentAgent ? (
<AgentInputArea
agentId={agentId}
serverId={serverId}
autoFocus
isSubmitLoading={showPendingCreateSubmitLoading}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={(height) => {
logWebStickyBottom("screen_composer_height_change", {
agentId,
height,
});
streamViewRef.current?.prepareForViewportChange();
}}
onMessageSent={() => {
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
agentId,
});
streamViewRef.current?.scrollToBottom("message-sent");
}}
/>
) : null}
{viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay" ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
) : null}
<ToastViewport
toast={panelToast.toast}
onDismiss={panelToast.dismiss}
placement="panel"
/>
</View>
</FileDropZone>
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>
Please wait while we archive this agent.
</Text>
</View>
) : null}
</View>
);
}
function AgentSessionUnavailableState({
serverLabel,
connectionStatus,
lastError,
isUnknownDaemon = false,
}: {
serverLabel: string;
connectionStatus: HostRuntimeConnectionStatus;
lastError: string | null;
isUnknownDaemon?: boolean;
}) {
if (isUnknownDaemon) {
return (
<View style={styles.container}>
<View style={styles.centerState}>
<Text style={styles.errorText}>
Cannot open this agent because {serverLabel} is not configured on
this device.
</Text>
<Text style={styles.statusText}>
Add the host in Settings or open an agent on a configured server to
continue.
</Text>
</View>
</View>
);
}
const isConnecting = connectionStatus === "connecting";
const isPreparingSession = connectionStatus === "online";
return (
<View style={styles.container}>
<View style={styles.centerState}>
{isConnecting || isPreparingSession ? (
<>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>
{isPreparingSession
? `Preparing ${serverLabel} session...`
: `Connecting to ${serverLabel}...`}
</Text>
<Text style={styles.statusText}>
{isPreparingSession
? "We will show this agent in a moment."
: "We will show this agent once the host is online."}
</Text>
</>
) : (
<>
<Text style={styles.offlineTitle}>
Reconnecting to {serverLabel}...
</Text>
<Text style={styles.offlineDescription}>
We will show this agent again as soon as the host is reachable.
</Text>
{lastError ? (
<Text style={styles.offlineDetails}>{lastError}</Text>
) : null}
</>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flex: 1,
overflow: "hidden",
},
content: {
flex: 1,
},
historySyncOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: theme.colors.surface0,
alignItems: "center",
justifyContent: "center",
zIndex: 40,
},
archivingOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: "rgba(8, 10, 14, 0.86)",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[8],
gap: theme.spacing[3],
zIndex: 50,
},
archivingTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
archivingSubtitle: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
loadingText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
gap: theme.spacing[3],
},
errorContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
errorText: {
fontSize: theme.fontSize.lg,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
statusText: {
marginTop: theme.spacing[2],
textAlign: "center",
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
offlineTitle: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
offlineDescription: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
offlineDetails: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
}));
File diff suppressed because it is too large Load Diff
@@ -279,6 +279,25 @@ function ResolvedMobileActiveTabTrigger({
);
}
function WorkspaceDocumentTitleEffect({
label,
titleState,
}: {
label: string;
titleState: "ready" | "loading";
}) {
useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined") {
return;
}
const resolvedLabel = label.trim();
document.title =
titleState === "loading" ? "Loading..." : resolvedLabel || "Workspace";
}, [label, titleState]);
return null;
}
function MobileWorkspaceTabOption({
tab,
tabIndex,
@@ -1804,6 +1823,12 @@ function WorkspaceScreenContent({
});
const activeTabDescriptor = activeTab?.descriptor ?? null;
useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) {
return;
}
document.title = "Workspace";
}, [activeTabDescriptor]);
const buildPaneContentModel = useCallback(
(input: {
tab: WorkspaceTabDescriptor;
@@ -1890,6 +1915,20 @@ function WorkspaceScreenContent({
return (
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
{Platform.OS === "web" && activeTabDescriptor ? (
<WorkspaceTabPresentationResolver
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{(presentation) => (
<WorkspaceDocumentTitleEffect
label={presentation.label}
titleState={presentation.titleState}
/>
)}
</WorkspaceTabPresentationResolver>
) : null}
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
<ScreenHeader