* perf(app): reduce warm workspace switch work Retained workspaces avoided mounts but pane activity still rerendered the full composer, message input, and agent-control presentation. Keep the activity contract intact while moving focus-sensitive behavior behind small context consumers, and add a Playwright benchmark that measures rapid retained workspace switching on one browser clock. * perf(app): accelerate retained workspace switching Avoid repeated retained-stream layout reconciliation and unrelated workspace structure recomputation. Use the lightweight route transition while the host is already focused, while preserving targeted root POP_TO for returns from app-wide routes where it prevents duplicate hidden decks. * perf(app): defer composer focus during workspace switches * perf(app): retain recently used workspaces longer Keep up to ten workspace screens warm so normal multi-workspace navigation does not repeatedly rebuild recently used timelines. Inactive screens expire after ten minutes, while the active screen remains protected and capacity eviction stays least-recently-used. * fix(app): reconcile retained timeline geometry * fix(app): refresh retained reading position
1844 lines
56 KiB
TypeScript
1844 lines
56 KiB
TypeScript
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
import type { TFunction } from "i18next";
|
|
import { SquarePen } from "lucide-react-native";
|
|
import React, {
|
|
memo,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
useSyncExternalStore,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
|
import ReanimatedAnimated from "react-native-reanimated";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import invariant from "tiny-invariant";
|
|
import { shallow, useShallow } from "zustand/shallow";
|
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
|
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
|
|
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
|
import { SidebarCallout } from "@/components/sidebar-callout";
|
|
import { Composer } from "@/composer";
|
|
import { getActiveMessageSubmissions } from "@/composer/submission/model";
|
|
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
|
import { getProviderIcon } from "@/components/provider-icons";
|
|
import {
|
|
ToastViewport,
|
|
useToastHost,
|
|
type ToastApi,
|
|
type ToastState,
|
|
} from "@/components/toast-host";
|
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
|
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
|
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
|
import { isWeb } from "@/constants/platform";
|
|
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
|
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
|
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
|
|
import {
|
|
type AgentScreenAgent,
|
|
type AgentScreenContinuity,
|
|
type AgentScreenMissingState,
|
|
type AgentScreenViewState,
|
|
useAgentScreenStateMachine,
|
|
} from "@/hooks/use-agent-screen-state-machine";
|
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
|
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
|
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
|
|
import {
|
|
reconcileReconnectToastState,
|
|
type ReconnectToastState,
|
|
} from "@/panels/reconnect-toast-state";
|
|
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
|
import { RenderProfile } from "@/utils/render-profiler";
|
|
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
|
import {
|
|
type HostRuntimeConnectionStatus,
|
|
getHostRuntimeConnectionStatusSince,
|
|
useHostRuntimeClient,
|
|
useHostRuntimeConnectionStatus,
|
|
useHostRuntimeIsConnected,
|
|
useHostRuntimeLastError,
|
|
useHosts,
|
|
} from "@/runtime/host-runtime";
|
|
import {
|
|
deriveRouteBottomAnchorIntent,
|
|
deriveRouteBottomAnchorRequest,
|
|
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
|
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
|
import { AgentTaskList } from "@/composer/task-list";
|
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
|
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
|
import { usePanelStore } from "@/stores/panel-store";
|
|
import {
|
|
selectAgentTimelineState,
|
|
selectAgentTurnPresentation,
|
|
type Agent,
|
|
useSessionStore,
|
|
} from "@/stores/session-store";
|
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
|
import type { Theme } from "@/styles/theme";
|
|
import {
|
|
useArchiveFinishedSubagents,
|
|
useArchiveSubagent,
|
|
useDetachSubagent,
|
|
useSubagentsForParent,
|
|
} from "@/subagents";
|
|
import { SubagentsTrack } from "@/subagents/track";
|
|
import type { PendingPermission } from "@/types/shared";
|
|
import type { StreamItem } from "@/types/stream";
|
|
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
|
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
|
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
|
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
|
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
|
import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands";
|
|
|
|
interface ChatAgentStateShape {
|
|
serverId: string | null;
|
|
id: string | null;
|
|
provider?: Agent["provider"];
|
|
status: Agent["status"] | null;
|
|
cwd: string | null;
|
|
workspaceId?: string;
|
|
capabilities?: Agent["capabilities"];
|
|
currentModeId?: Agent["currentModeId"];
|
|
model?: Agent["model"];
|
|
thinkingOptionId?: Agent["thinkingOptionId"];
|
|
runtimeInfo?: Agent["runtimeInfo"];
|
|
features?: Agent["features"];
|
|
lastError?: Agent["lastError"] | null;
|
|
}
|
|
|
|
const RECONNECT_TOAST_DELAY_MS = 1_000;
|
|
|
|
const reconnectToastStateByServerId = new Map<string, ReconnectToastState>();
|
|
|
|
interface ChatAgentSelectedState extends ChatAgentStateShape {
|
|
archivedAt: Date | null;
|
|
requiresAttention: boolean;
|
|
attentionReason: Agent["attentionReason"] | null;
|
|
}
|
|
|
|
function resolveChatAgentFromSession(
|
|
state: ReturnType<typeof useSessionStore.getState>,
|
|
serverId: string,
|
|
agentId: string | undefined,
|
|
): Agent | null {
|
|
if (!agentId) return null;
|
|
const session = state.sessions[serverId];
|
|
return session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null;
|
|
}
|
|
|
|
const EMPTY_CHAT_AGENT_STATE: ChatAgentSelectedState = {
|
|
serverId: null,
|
|
id: null,
|
|
status: null,
|
|
cwd: null,
|
|
lastError: null,
|
|
archivedAt: null,
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
};
|
|
|
|
function selectChatAgentState(
|
|
state: ReturnType<typeof useSessionStore.getState>,
|
|
serverId: string,
|
|
agentId: string | undefined,
|
|
): ChatAgentSelectedState {
|
|
const agent = resolveChatAgentFromSession(state, serverId, agentId);
|
|
if (!agent) return EMPTY_CHAT_AGENT_STATE;
|
|
return {
|
|
serverId: agent.serverId,
|
|
id: agent.id,
|
|
provider: agent.provider,
|
|
status: agent.status,
|
|
cwd: agent.cwd,
|
|
workspaceId: agent.workspaceId,
|
|
capabilities: agent.capabilities,
|
|
currentModeId: agent.currentModeId,
|
|
model: agent.model,
|
|
thinkingOptionId: agent.thinkingOptionId,
|
|
runtimeInfo: agent.runtimeInfo,
|
|
features: agent.features,
|
|
lastError: agent.lastError ?? null,
|
|
archivedAt: agent.archivedAt ?? null,
|
|
requiresAttention: agent.requiresAttention ?? false,
|
|
attentionReason: agent.attentionReason ?? null,
|
|
};
|
|
}
|
|
|
|
function buildChatAgentFromState(
|
|
state: ChatAgentStateShape,
|
|
projectPlacement: Agent["projectPlacement"] | null,
|
|
): AgentScreenAgent | null {
|
|
if (!state.serverId || !state.id || !state.status || !state.cwd) {
|
|
return null;
|
|
}
|
|
return {
|
|
serverId: state.serverId,
|
|
id: state.id,
|
|
provider: state.provider,
|
|
status: state.status,
|
|
cwd: state.cwd,
|
|
workspaceId: state.workspaceId,
|
|
capabilities: state.capabilities,
|
|
currentModeId: state.currentModeId,
|
|
model: state.model,
|
|
thinkingOptionId: state.thinkingOptionId,
|
|
runtimeInfo: state.runtimeInfo,
|
|
features: state.features,
|
|
lastError: state.lastError ?? null,
|
|
projectPlacement,
|
|
};
|
|
}
|
|
|
|
function renderChatAgentNonReadyView(args: {
|
|
viewState: AgentScreenViewState;
|
|
effectiveAgent: AgentScreenAgent | null;
|
|
t: TFunction;
|
|
}): React.ReactElement | null {
|
|
const { viewState, effectiveAgent, t } = args;
|
|
if (viewState.tag === "not_found") {
|
|
return (
|
|
<View style={styles.container} testID="agent-not-found">
|
|
<View style={styles.errorContainer}>
|
|
<Text style={styles.errorText}>{t("agentPanel.states.notFound")}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
if (viewState.tag === "error") {
|
|
return (
|
|
<View style={styles.container} testID="agent-load-error">
|
|
<View style={styles.errorContainer}>
|
|
<Text style={styles.errorText}>{t("agentPanel.states.failedToLoad")}</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}>
|
|
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatProviderLabel(provider: Agent["provider"]): string {
|
|
if (!provider) {
|
|
return "Agent";
|
|
}
|
|
return provider
|
|
.split(/[-_\s]+/)
|
|
.filter((part) => part.length > 0)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null {
|
|
if (typeof title !== "string") {
|
|
return null;
|
|
}
|
|
const normalized = title.trim();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
if (normalized.toLowerCase() === "new agent") {
|
|
return null;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean {
|
|
return !agent.archivedAt && Boolean(agent.projectPlacement);
|
|
}
|
|
|
|
type FetchAgentResult = Awaited<ReturnType<DaemonClient["fetchAgent"]>>;
|
|
|
|
function storeFetchedAgentDetail(input: {
|
|
serverId: string;
|
|
result: NonNullable<FetchAgentResult>;
|
|
}): Agent {
|
|
const normalized = normalizeAgentSnapshot(input.result.agent, input.serverId);
|
|
const hydrated: Agent = applyLegacyDaemonWorkspaceOwnership({
|
|
serverId: input.serverId,
|
|
agent: {
|
|
...normalized,
|
|
projectPlacement: input.result.project,
|
|
},
|
|
});
|
|
const store = useSessionStore.getState();
|
|
|
|
if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) {
|
|
store.setAgents(input.serverId, (previous) => {
|
|
const next = new Map(previous);
|
|
next.set(hydrated.id, hydrated);
|
|
return next;
|
|
});
|
|
} else {
|
|
store.setAgentDetails(input.serverId, (previous) => {
|
|
const next = new Map(previous);
|
|
next.set(hydrated.id, hydrated);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
store.setPendingPermissions(input.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;
|
|
});
|
|
|
|
return hydrated;
|
|
}
|
|
|
|
function useAgentPanelDescriptor(
|
|
target: { kind: "agent"; agentId: string },
|
|
context: { serverId: string },
|
|
): PanelDescriptor {
|
|
const descriptorState = useSessionStore(
|
|
useShallow((state) => {
|
|
const session = state.sessions[context.serverId];
|
|
const agent =
|
|
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
|
|
return {
|
|
provider: agent?.provider ?? "codex",
|
|
title: agent?.title ?? null,
|
|
status: agent?.status ?? null,
|
|
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
|
|
requiresAttention: agent?.requiresAttention ?? false,
|
|
attentionReason: agent?.attentionReason ?? null,
|
|
isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive,
|
|
};
|
|
}),
|
|
);
|
|
const provider = descriptorState.provider;
|
|
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
|
const icon = getProviderIcon(provider);
|
|
|
|
return {
|
|
label: label ?? "",
|
|
subtitle: `${formatProviderLabel(provider)} agent`,
|
|
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
|
|
titleState: label ? "ready" : "loading",
|
|
icon,
|
|
statusBucket: descriptorState.status
|
|
? deriveSidebarStateBucket({
|
|
status: descriptorState.isTurnActive ? "running" : descriptorState.status,
|
|
pendingPermissionCount: descriptorState.pendingPermissionCount,
|
|
requiresAttention: descriptorState.requiresAttention,
|
|
attentionReason: descriptorState.attentionReason,
|
|
})
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function AgentPanel() {
|
|
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
|
const { isInteractive } = usePaneFocus();
|
|
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
|
|
|
return (
|
|
<AgentPanelContent
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
agentId={target.agentId}
|
|
isPaneFocused={isInteractive}
|
|
onOpenWorkspaceFile={openFileInWorkspace}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function DraftPanel() {
|
|
const {
|
|
serverId,
|
|
workspaceId,
|
|
tabId,
|
|
target,
|
|
openFileInWorkspace,
|
|
openImportSheet,
|
|
retargetCurrentTab,
|
|
} = usePaneContext();
|
|
const { isInteractive } = usePaneFocus();
|
|
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
|
|
|
const handleCreated = useCallback(
|
|
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
|
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
|
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
|
|
useSessionStore.getState().setAgents(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
next.set(agentSnapshot.id, agent);
|
|
return next;
|
|
});
|
|
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
|
},
|
|
[retargetCurrentTab, serverId],
|
|
);
|
|
|
|
return (
|
|
<WorkspaceDraftAgentTab
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
tabId={tabId}
|
|
draftId={target.draftId}
|
|
initialSetup={target.setup}
|
|
isPaneFocused={isInteractive}
|
|
onOpenWorkspaceFile={openFileInWorkspace}
|
|
onCreated={handleCreated}
|
|
onOpenImportSheet={openImportSheet}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function AgentConversationPanel() {
|
|
const { target } = usePaneContext();
|
|
if (target.kind === "draft") {
|
|
return <DraftPanel />;
|
|
}
|
|
if (target.kind === "agent") {
|
|
return <AgentPanel />;
|
|
}
|
|
invariant(false, "AgentConversationPanel requires an agent or draft target");
|
|
}
|
|
|
|
export const agentPanelRegistration: PanelRegistration<"agent"> = {
|
|
kind: "agent",
|
|
component: AgentConversationPanel,
|
|
useDescriptor: useAgentPanelDescriptor,
|
|
};
|
|
|
|
export function useDraftPanelDescriptor(
|
|
target: { kind: "draft"; draftId: string },
|
|
context: { serverId: string },
|
|
) {
|
|
const createDescriptorState = useCreateFlowStore(
|
|
useShallow((state) => {
|
|
const pending = state.pendingByDraftId[target.draftId];
|
|
if (pending?.serverId !== context.serverId || pending.lifecycle !== "active") {
|
|
return {
|
|
isCreating: false,
|
|
pendingPrompt: null,
|
|
};
|
|
}
|
|
return {
|
|
isCreating: true,
|
|
pendingPrompt: pending.text,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return buildDraftPanelDescriptor({
|
|
...createDescriptorState,
|
|
icon: SquarePen,
|
|
});
|
|
}
|
|
|
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
|
const EMPTY_MESSAGE_SUBMISSIONS = [] as const;
|
|
const EMPTY_PENDING_PERMISSIONS = new Map<string, PendingPermission>();
|
|
const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = [];
|
|
|
|
type RouteBottomAnchorRequest = ReturnType<typeof deriveRouteBottomAnchorRequest>;
|
|
|
|
function findActiveCreateHandoff(input: {
|
|
pendingByDraftId: ReturnType<typeof useCreateFlowStore.getState>["pendingByDraftId"];
|
|
serverId: string;
|
|
agentId?: string;
|
|
}): boolean {
|
|
if (!input.agentId) {
|
|
return false;
|
|
}
|
|
return Object.values(input.pendingByDraftId).some(
|
|
(pending) =>
|
|
pending.lifecycle === "sent" &&
|
|
pending.serverId === input.serverId &&
|
|
pending.agentId === input.agentId,
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
type AgentLookupState =
|
|
| { tag: "idle" }
|
|
| { tag: "loading" }
|
|
| { tag: "not_found"; message: string }
|
|
| { tag: "error"; message: string };
|
|
|
|
function AgentPanelContent({
|
|
serverId,
|
|
workspaceId,
|
|
agentId,
|
|
isPaneFocused,
|
|
onOpenWorkspaceFile,
|
|
}: {
|
|
serverId: string;
|
|
workspaceId: string;
|
|
agentId: string;
|
|
isPaneFocused: boolean;
|
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const resolvedAgentId = agentId.trim() || undefined;
|
|
const resolvedServerId = serverId.trim() || undefined;
|
|
const daemons = useHosts();
|
|
const runtimeServerId = resolvedServerId ?? "";
|
|
const runtimeClient = useHostRuntimeClient(runtimeServerId);
|
|
const runtimeIsConnected = useHostRuntimeIsConnected(runtimeServerId);
|
|
const runtimeConnectionStatus = useHostRuntimeConnectionStatus(runtimeServerId);
|
|
const runtimeLastError = useHostRuntimeLastError(runtimeServerId);
|
|
const hasCachedAgent = useSessionStore((state) => {
|
|
if (!resolvedServerId || !resolvedAgentId) return false;
|
|
const session = state.sessions[resolvedServerId];
|
|
return Boolean(
|
|
session?.agents.has(resolvedAgentId) || session?.agentDetails.has(resolvedAgentId),
|
|
);
|
|
});
|
|
|
|
const connectionServerId = resolvedServerId ?? null;
|
|
const daemon = connectionServerId
|
|
? (daemons.find((entry) => entry.serverId === connectionServerId) ?? null)
|
|
: null;
|
|
const serverLabel =
|
|
daemon?.label ?? connectionServerId ?? t("agentPanel.unavailable.selectedHost");
|
|
const isUnknownDaemon = Boolean(connectionServerId && !daemon);
|
|
const connectionStatus: HostRuntimeConnectionStatus =
|
|
isUnknownDaemon && runtimeConnectionStatus === "connecting"
|
|
? "offline"
|
|
: runtimeConnectionStatus;
|
|
const lastConnectionError = runtimeLastError;
|
|
|
|
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) {
|
|
return (
|
|
<AgentSessionUnavailableState
|
|
serverLabel={serverLabel}
|
|
connectionStatus={connectionStatus}
|
|
lastError={lastConnectionError}
|
|
isUnknownDaemon={isUnknownDaemon}
|
|
t={t}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AgentPanelBody
|
|
serverId={resolvedServerId}
|
|
workspaceId={workspaceId}
|
|
agentId={resolvedAgentId}
|
|
isPaneFocused={isPaneFocused}
|
|
client={runtimeClient}
|
|
isConnected={runtimeIsConnected}
|
|
connectionStatus={connectionStatus}
|
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function AgentPanelBody({
|
|
serverId,
|
|
workspaceId,
|
|
agentId,
|
|
isPaneFocused,
|
|
client,
|
|
isConnected,
|
|
connectionStatus,
|
|
onOpenWorkspaceFile,
|
|
}: {
|
|
serverId: string;
|
|
workspaceId: string;
|
|
agentId?: string;
|
|
isPaneFocused: boolean;
|
|
client: ReturnType<typeof useHostRuntimeClient>;
|
|
isConnected: boolean;
|
|
connectionStatus: HostRuntimeConnectionStatus;
|
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
|
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
|
const projectPlacement = useStoreWithEqualityFn(
|
|
useSessionStore,
|
|
(state) => {
|
|
if (!agentId) {
|
|
return null;
|
|
}
|
|
const session = state.sessions[serverId];
|
|
return (
|
|
session?.agents?.get(agentId)?.projectPlacement ??
|
|
session?.agentDetails?.get(agentId)?.projectPlacement ??
|
|
null
|
|
);
|
|
},
|
|
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
|
);
|
|
const agentState = useSessionStore(
|
|
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
|
);
|
|
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
|
const lookupAttemptTokenRef = useRef(0);
|
|
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent);
|
|
|
|
useEffect(() => {
|
|
lookupAttemptTokenRef.current += 1;
|
|
setLookupState({ tag: "idle" });
|
|
}, [agentId, serverId]);
|
|
|
|
useEffect(() => {
|
|
if (!agentId) {
|
|
return;
|
|
}
|
|
if (agentState.id) {
|
|
if (workspaceKey) {
|
|
resolvePendingAgent(workspaceKey, agentId);
|
|
}
|
|
if (lookupState.tag !== "idle") {
|
|
setLookupState({ tag: "idle" });
|
|
}
|
|
return;
|
|
}
|
|
if (!client || !isConnected || !hasSession) {
|
|
return;
|
|
}
|
|
if (lookupState.tag === "loading" || lookupState.tag === "not_found") {
|
|
return;
|
|
}
|
|
|
|
setLookupState({ tag: "loading" });
|
|
const attemptToken = ++lookupAttemptTokenRef.current;
|
|
|
|
client
|
|
.fetchAgent({ agentId })
|
|
.then((result) => {
|
|
if (attemptToken !== lookupAttemptTokenRef.current) {
|
|
return;
|
|
}
|
|
if (!result) {
|
|
if (workspaceKey) {
|
|
resolvePendingAgent(workspaceKey, agentId);
|
|
}
|
|
setLookupState({
|
|
tag: "not_found",
|
|
message: `Agent not found: ${agentId}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
storeFetchedAgentDetail({ serverId, result });
|
|
if (workspaceKey) {
|
|
resolvePendingAgent(workspaceKey, agentId);
|
|
}
|
|
setLookupState({ tag: "idle" });
|
|
return;
|
|
})
|
|
.catch((error) => {
|
|
if (attemptToken !== lookupAttemptTokenRef.current) {
|
|
return;
|
|
}
|
|
const message = toErrorMessage(error);
|
|
if (isNotFoundErrorMessage(message)) {
|
|
if (workspaceKey) {
|
|
resolvePendingAgent(workspaceKey, agentId);
|
|
}
|
|
setLookupState({ tag: "not_found", message });
|
|
return;
|
|
}
|
|
setLookupState({ tag: "error", message });
|
|
});
|
|
}, [
|
|
agentId,
|
|
agentState.id,
|
|
client,
|
|
hasSession,
|
|
isConnected,
|
|
lookupState.tag,
|
|
resolvePendingAgent,
|
|
serverId,
|
|
workspaceKey,
|
|
]);
|
|
|
|
if (lookupState.tag === "not_found") {
|
|
return (
|
|
<View style={styles.container} testID="agent-not-found">
|
|
<View style={styles.errorContainer}>
|
|
<Text style={styles.errorText}>{t("agentPanel.states.notFound")}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (lookupState.tag === "error") {
|
|
return (
|
|
<View style={styles.container} testID="agent-load-error">
|
|
<View style={styles.errorContainer}>
|
|
<Text style={styles.errorText}>{t("agentPanel.states.failedToLoad")}</Text>
|
|
<Text style={styles.statusText}>{lookupState.message}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const agent: AgentScreenAgent | null =
|
|
agentState.serverId && agentState.id && agentState.status && agentState.cwd
|
|
? {
|
|
serverId: agentState.serverId,
|
|
id: agentState.id,
|
|
provider: agentState.provider,
|
|
status: agentState.status,
|
|
cwd: agentState.cwd,
|
|
workspaceId: agentState.workspaceId,
|
|
capabilities: agentState.capabilities,
|
|
currentModeId: agentState.currentModeId,
|
|
model: agentState.model,
|
|
thinkingOptionId: agentState.thinkingOptionId,
|
|
runtimeInfo: agentState.runtimeInfo,
|
|
features: agentState.features,
|
|
lastError: agentState.lastError ?? null,
|
|
projectPlacement,
|
|
}
|
|
: null;
|
|
|
|
if (!agent) {
|
|
return (
|
|
<View style={styles.container} testID="agent-loading">
|
|
<View style={styles.errorContainer}>
|
|
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ChatAgentContent
|
|
serverId={serverId}
|
|
agentId={agentId}
|
|
isPaneFocused={isPaneFocused}
|
|
client={client}
|
|
isConnected={isConnected}
|
|
connectionStatus={connectionStatus}
|
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ChatAgentContent({
|
|
serverId,
|
|
agentId,
|
|
isPaneFocused,
|
|
client,
|
|
isConnected,
|
|
connectionStatus,
|
|
onOpenWorkspaceFile,
|
|
}: {
|
|
serverId: string;
|
|
agentId?: string;
|
|
isPaneFocused: boolean;
|
|
client: ReturnType<typeof useHostRuntimeClient>;
|
|
isConnected: boolean;
|
|
connectionStatus: HostRuntimeConnectionStatus;
|
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const isPaneVisible = useRetainedPanelActive();
|
|
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
|
|
const { isArchivingAgent } = useArchiveAgent();
|
|
const streamViewRef = useRef<AgentStreamViewHandle>(null);
|
|
const clearOnAgentBlurRef = useRef<() => void>(() => {});
|
|
const wasPaneFocusedRef = useRef(isPaneFocused);
|
|
const reconnectToastPresentedRef = useRef(false);
|
|
const initAttemptTokenRef = useRef(0);
|
|
const routeBottomAnchorRequestRef = useRef<{
|
|
routeKey: string;
|
|
reason: "initial-entry" | "resume";
|
|
} | null>(null);
|
|
const agentState = useSessionStore(
|
|
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
|
);
|
|
const projectPlacement = useStoreWithEqualityFn(
|
|
useSessionStore,
|
|
(state) => {
|
|
if (!agentId) {
|
|
return null;
|
|
}
|
|
const session = state.sessions[serverId];
|
|
return (
|
|
session?.agents?.get(agentId)?.projectPlacement ??
|
|
session?.agentDetails?.get(agentId)?.projectPlacement ??
|
|
null
|
|
);
|
|
},
|
|
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
|
);
|
|
const isInitializingFromMap = useSessionStore((state) =>
|
|
agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false,
|
|
);
|
|
const historySyncGeneration = useSessionStore(
|
|
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0,
|
|
);
|
|
const replicaTimelineStatus = useSessionStore((state) =>
|
|
agentId
|
|
? selectAgentTimelineState(state.sessions[serverId], agentId).status
|
|
: ("cold" as const),
|
|
);
|
|
const hasAppliedAuthoritativeHistory = replicaTimelineStatus === "synced";
|
|
const agentHistorySyncGeneration = useSessionStore((state) =>
|
|
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
|
|
);
|
|
const viewedTimelineSync = useSessionStore(
|
|
(state) => state.sessions[serverId]?.viewedTimelineSync ?? null,
|
|
);
|
|
const subscribeToVisibilityCatchUp = useCallback(
|
|
(listener: () => void) => viewedTimelineSync?.subscribe(listener) ?? (() => {}),
|
|
[viewedTimelineSync],
|
|
);
|
|
const readTimelineStatus = useCallback(
|
|
() =>
|
|
!agentId || !viewedTimelineSync
|
|
? ("ready" as const)
|
|
: viewedTimelineSync.getAgentTimelineStatus(agentId),
|
|
[agentId, viewedTimelineSync],
|
|
);
|
|
const timelineStatus = useSyncExternalStore(
|
|
subscribeToVisibilityCatchUp,
|
|
readTimelineStatus,
|
|
readTimelineStatus,
|
|
);
|
|
const visibilityCatchUpStatus = isPaneVisible ? timelineStatus : "ready";
|
|
const hasActiveCreateHandoff = useCreateFlowStore((state) =>
|
|
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
|
|
);
|
|
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
|
const { ensureAgentIsInitialized } = useAgentInitialization({
|
|
serverId,
|
|
client: hasSession ? client : null,
|
|
});
|
|
const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({
|
|
kind: "idle",
|
|
});
|
|
|
|
const hasHydratedHistoryBefore =
|
|
hasAppliedAuthoritativeHistory || replicaTimelineStatus === "painted";
|
|
|
|
const attentionController = useAgentAttentionClear({
|
|
agentId,
|
|
client,
|
|
isConnected,
|
|
requiresAttention: agentState.requiresAttention,
|
|
attentionReason: agentState.attentionReason,
|
|
isScreenFocused: isPaneFocused,
|
|
});
|
|
useEffect(() => {
|
|
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
|
|
}, [attentionController.clearOnAgentBlur]);
|
|
|
|
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
|
|
mode: "translate",
|
|
});
|
|
const shouldPresentReconnectToast =
|
|
isPaneVisible && connectionStatus !== "online" && connectionStatus !== "idle";
|
|
|
|
useEffect(() => {
|
|
if (connectionStatus === "online" || connectionStatus === "idle") {
|
|
reconnectToastStateByServerId.delete(serverId);
|
|
}
|
|
|
|
if (!shouldPresentReconnectToast) {
|
|
if (reconnectToastPresentedRef.current) {
|
|
reconnectToastPresentedRef.current = false;
|
|
dismissToast();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const startedAt = getHostRuntimeConnectionStatusSince(serverId) ?? Date.now();
|
|
const previousReconnectToastState = reconnectToastStateByServerId.get(serverId);
|
|
const reconnectToastState = reconcileReconnectToastState(
|
|
previousReconnectToastState,
|
|
startedAt,
|
|
);
|
|
if (reconnectToastState !== previousReconnectToastState) {
|
|
reconnectToastStateByServerId.set(serverId, reconnectToastState);
|
|
}
|
|
|
|
if (reconnectToastState.presented) {
|
|
if (!reconnectToastPresentedRef.current) {
|
|
reconnectToastPresentedRef.current = true;
|
|
toastApi.show(t("agentPanel.states.reconnecting"), {
|
|
durationMs: null,
|
|
icon: (
|
|
<View
|
|
accessible={false}
|
|
testID="agent-reconnecting-status-dot"
|
|
style={styles.reconnectingStatusDot}
|
|
/>
|
|
),
|
|
testID: "agent-reconnecting-toast",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const delayMs = Math.max(0, startedAt + RECONNECT_TOAST_DELAY_MS - Date.now());
|
|
const timer = setTimeout(() => {
|
|
if (reconnectToastStateByServerId.get(serverId) !== reconnectToastState) {
|
|
return;
|
|
}
|
|
reconnectToastState.presented = true;
|
|
reconnectToastPresentedRef.current = true;
|
|
toastApi.show(t("agentPanel.states.reconnecting"), {
|
|
durationMs: null,
|
|
icon: (
|
|
<View
|
|
accessible={false}
|
|
testID="agent-reconnecting-status-dot"
|
|
style={styles.reconnectingStatusDot}
|
|
/>
|
|
),
|
|
testID: "agent-reconnecting-toast",
|
|
});
|
|
}, delayMs);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [connectionStatus, dismissToast, serverId, shouldPresentReconnectToast, toastApi, t]);
|
|
|
|
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
|
|
|
|
useEffect(() => {
|
|
if (wasPaneFocusedRef.current && !isPaneFocused) {
|
|
clearOnAgentBlurRef.current();
|
|
}
|
|
wasPaneFocusedRef.current = isPaneFocused;
|
|
}, [isPaneFocused]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (wasPaneFocusedRef.current) {
|
|
clearOnAgentBlurRef.current();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const isInitializing = agentId ? isInitializingFromMap : 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 agent = useMemo<AgentScreenAgent | null>(
|
|
() => buildChatAgentFromState(agentState, projectPlacement),
|
|
[agentState, projectPlacement],
|
|
);
|
|
const continuity = useMemo<AgentScreenContinuity>(() => {
|
|
if (!hasActiveCreateHandoff || !agentId) {
|
|
return { kind: "none" };
|
|
}
|
|
return {
|
|
kind: "optimistic-create",
|
|
agent: {
|
|
serverId,
|
|
id: agentId,
|
|
status: "running",
|
|
cwd: agent?.cwd ?? ".",
|
|
projectPlacement: agent?.projectPlacement ?? null,
|
|
},
|
|
};
|
|
}, [agent, agentId, hasActiveCreateHandoff, serverId]);
|
|
|
|
const viewState = useAgentScreenStateMachine({
|
|
routeKey: `${serverId}:${agentId ?? ""}`,
|
|
input: {
|
|
agent: agent ?? null,
|
|
isArchived: agentState.archivedAt !== null,
|
|
missingAgentState,
|
|
isConnected,
|
|
isArchivingCurrentAgent,
|
|
isHistorySyncing,
|
|
needsAuthoritativeSync,
|
|
visibilityCatchUpStatus,
|
|
continuity,
|
|
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],
|
|
);
|
|
|
|
const handleComposerHeightChange = useCallback(
|
|
(_height: number) => {
|
|
if (!agentId) {
|
|
return;
|
|
}
|
|
streamViewRef.current?.prepareForViewportChange();
|
|
},
|
|
[agentId],
|
|
);
|
|
|
|
const handleMessageSent = useCallback(() => {
|
|
if (!agentId) {
|
|
return;
|
|
}
|
|
streamViewRef.current?.scrollToBottom("message-sent");
|
|
}, [agentId]);
|
|
|
|
useEffect(() => {
|
|
initAttemptTokenRef.current += 1;
|
|
setMissingAgentState({ kind: "idle" });
|
|
}, [agentId, serverId]);
|
|
|
|
useEffect(() => {
|
|
if (!agentId) {
|
|
return;
|
|
}
|
|
if (agentState.archivedAt) {
|
|
return;
|
|
}
|
|
if (agentState.id && hasAppliedAuthoritativeHistory) {
|
|
if (
|
|
missingAgentState.kind === "resolving" ||
|
|
missingAgentState.kind === "not_found" ||
|
|
missingAgentState.kind === "error"
|
|
) {
|
|
setMissingAgentState(reconcileMissingAgentStateWithPresentAgent);
|
|
}
|
|
return;
|
|
}
|
|
if (!client || !isPaneVisible || !isConnected || !hasSession) {
|
|
return;
|
|
}
|
|
if (
|
|
missingAgentState.kind === "resolving" ||
|
|
missingAgentState.kind === "not_found" ||
|
|
missingAgentState.kind === "error"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setMissingAgentState({ kind: "resolving" });
|
|
const attemptToken = ++initAttemptTokenRef.current;
|
|
|
|
ensureAgentIsInitialized(agentId)
|
|
.then(async () => {
|
|
if (attemptToken !== initAttemptTokenRef.current) {
|
|
return;
|
|
}
|
|
const currentSession = useSessionStore.getState().sessions[serverId];
|
|
const currentAgent =
|
|
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.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;
|
|
}
|
|
storeFetchedAgentDetail({ serverId, result });
|
|
}
|
|
if (attemptToken !== initAttemptTokenRef.current) {
|
|
return;
|
|
}
|
|
setMissingAgentState({ kind: "idle" });
|
|
return;
|
|
})
|
|
.catch((error) => {
|
|
if (attemptToken !== initAttemptTokenRef.current) {
|
|
return;
|
|
}
|
|
const message = toErrorMessage(error);
|
|
if (isNotFoundErrorMessage(message)) {
|
|
setMissingAgentState({ kind: "not_found", message });
|
|
return;
|
|
}
|
|
setMissingAgentState({ kind: "error", message });
|
|
});
|
|
}, [
|
|
agentState.id,
|
|
agentState.archivedAt,
|
|
hasAppliedAuthoritativeHistory,
|
|
agentId,
|
|
client,
|
|
ensureAgentIsInitialized,
|
|
hasSession,
|
|
isConnected,
|
|
isPaneVisible,
|
|
missingAgentState.kind,
|
|
serverId,
|
|
]);
|
|
|
|
const animatedContentStyle = useMemo(
|
|
() => [animatedStaticStyles.content, animatedKeyboardStyle],
|
|
[animatedKeyboardStyle],
|
|
);
|
|
|
|
const nonReadyView = renderChatAgentNonReadyView({
|
|
viewState,
|
|
effectiveAgent,
|
|
t,
|
|
});
|
|
if (nonReadyView) return nonReadyView;
|
|
invariant(agentId, "agent id is defined when agent content is ready");
|
|
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
|
const agentCwd = agentState.cwd;
|
|
invariant(agentCwd, "agent cwd is defined when agent content is ready");
|
|
const showHistorySyncOverlay =
|
|
viewState.tag === "ready" &&
|
|
viewState.sync.status === "catching_up" &&
|
|
viewState.sync.ui === "overlay";
|
|
const showHistorySyncError = viewState.tag === "ready" && viewState.sync.status === "sync_error";
|
|
|
|
return (
|
|
<ChatAgentReadyContent
|
|
serverId={serverId}
|
|
agentId={agentId}
|
|
isPaneFocused={isPaneFocused}
|
|
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
|
agentState={agentState}
|
|
effectiveAgent={effectiveAgent}
|
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
|
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
|
toastApi={toastApi}
|
|
toast={toastState}
|
|
dismiss={dismissToast}
|
|
streamViewRef={streamViewRef}
|
|
animatedContentStyle={animatedContentStyle}
|
|
handleComposerHeightChange={handleComposerHeightChange}
|
|
handleMessageSent={handleMessageSent}
|
|
showHistorySyncOverlay={showHistorySyncOverlay}
|
|
showHistorySyncError={showHistorySyncError}
|
|
cwd={agentCwd}
|
|
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
|
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|
serverId,
|
|
agentId,
|
|
isPaneFocused,
|
|
isArchivingCurrentAgent,
|
|
agentState,
|
|
effectiveAgent,
|
|
routeBottomAnchorRequest,
|
|
hasAppliedAuthoritativeHistory,
|
|
toastApi,
|
|
toast,
|
|
dismiss,
|
|
streamViewRef,
|
|
animatedContentStyle,
|
|
handleComposerHeightChange,
|
|
handleMessageSent,
|
|
showHistorySyncOverlay,
|
|
showHistorySyncError,
|
|
cwd,
|
|
onAttentionInputFocus,
|
|
onAttentionPromptSend,
|
|
onOpenWorkspaceFile,
|
|
}: {
|
|
serverId: string;
|
|
agentId: string;
|
|
isPaneFocused: boolean;
|
|
isArchivingCurrentAgent: boolean;
|
|
agentState: ChatAgentSelectedState;
|
|
effectiveAgent: AgentScreenAgent;
|
|
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
|
hasAppliedAuthoritativeHistory: boolean;
|
|
toastApi: ToastApi;
|
|
toast: ToastState | null;
|
|
dismiss: () => void;
|
|
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
|
animatedContentStyle: object[];
|
|
handleComposerHeightChange: (height: number) => void;
|
|
handleMessageSent: () => void;
|
|
showHistorySyncOverlay: boolean;
|
|
showHistorySyncError: boolean;
|
|
cwd: string;
|
|
onAttentionInputFocus: () => void;
|
|
onAttentionPromptSend: () => void;
|
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const rawAgentInputDraft = useAgentInputDraft({
|
|
draftKey: buildDraftStoreKey({
|
|
serverId,
|
|
agentId,
|
|
}),
|
|
});
|
|
// Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out
|
|
// when only toast state changes (which does not affect any draft field).
|
|
const {
|
|
text,
|
|
editText,
|
|
replaceText,
|
|
textReplacementKey,
|
|
attachments,
|
|
setAttachments,
|
|
clear,
|
|
isHydrated,
|
|
attachmentFocusRequestId,
|
|
composerState,
|
|
} = rawAgentInputDraft;
|
|
const agentInputDraft = useMemo(
|
|
(): AgentInputDraft => ({
|
|
text,
|
|
editText,
|
|
replaceText,
|
|
textReplacementKey,
|
|
attachments,
|
|
setAttachments,
|
|
clear,
|
|
isHydrated,
|
|
attachmentFocusRequestId,
|
|
composerState,
|
|
}),
|
|
[
|
|
text,
|
|
editText,
|
|
replaceText,
|
|
textReplacementKey,
|
|
attachments,
|
|
setAttachments,
|
|
clear,
|
|
isHydrated,
|
|
attachmentFocusRequestId,
|
|
composerState,
|
|
],
|
|
);
|
|
const streamSection = (
|
|
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
|
<AgentStreamSection
|
|
streamViewRef={streamViewRef}
|
|
serverId={serverId}
|
|
agentId={agentId}
|
|
agent={effectiveAgent}
|
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
|
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
|
toast={toastApi}
|
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
/>
|
|
</RenderProfile>
|
|
);
|
|
const composerSection = (
|
|
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
|
<AgentComposerSection
|
|
agentId={agentId}
|
|
serverId={serverId}
|
|
isPaneFocused={isPaneFocused}
|
|
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
|
archivedAt={agentState.archivedAt}
|
|
cwd={cwd}
|
|
isSubmitLoading={false}
|
|
agentInputDraft={agentInputDraft}
|
|
onAttentionInputFocus={onAttentionInputFocus}
|
|
onAttentionPromptSend={onAttentionPromptSend}
|
|
onComposerHeightChange={handleComposerHeightChange}
|
|
onMessageSent={handleMessageSent}
|
|
/>
|
|
</RenderProfile>
|
|
);
|
|
const streamContent = (
|
|
<ReanimatedAnimated.View style={animatedContentStyle}>{streamSection}</ReanimatedAnimated.View>
|
|
);
|
|
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
|
|
|
|
return (
|
|
<RewindComposerRestoreProvider
|
|
text={agentInputDraft.text}
|
|
setText={agentInputDraft.replaceText}
|
|
>
|
|
<View style={styles.root}>
|
|
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
|
|
{contentContainer}
|
|
|
|
{showHistorySyncError ? (
|
|
<SidebarCallout
|
|
title={t("agentPanel.states.timelineSyncFailed")}
|
|
variant="error"
|
|
testID="agent-timeline-sync-error"
|
|
/>
|
|
) : null}
|
|
|
|
{composerSection}
|
|
|
|
{showHistorySyncOverlay ? (
|
|
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
|
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
|
</View>
|
|
) : null}
|
|
|
|
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
|
|
</FileDropZone>
|
|
|
|
{isArchivingCurrentAgent ? (
|
|
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
|
<ThemedLoadingSpinner size="large" uniProps={foregroundColorMapping} />
|
|
<Text style={styles.archivingTitle}>{t("agentPanel.states.archivingTitle")}</Text>
|
|
<Text style={styles.archivingSubtitle}>{t("agentPanel.states.archivingSubtitle")}</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</RewindComposerRestoreProvider>
|
|
);
|
|
});
|
|
|
|
const AgentStreamSection = memo(function AgentStreamSection({
|
|
streamViewRef,
|
|
serverId,
|
|
agentId,
|
|
agent,
|
|
routeBottomAnchorRequest,
|
|
hasAppliedAuthoritativeHistory,
|
|
toast,
|
|
onOpenWorkspaceFile,
|
|
}: {
|
|
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
|
serverId: string;
|
|
agentId?: string;
|
|
agent: AgentScreenAgent;
|
|
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
|
hasAppliedAuthoritativeHistory: boolean;
|
|
toast: ReturnType<typeof useToastHost>["api"];
|
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
|
}) {
|
|
const streamItemsRaw = useSessionStore((state) =>
|
|
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
|
);
|
|
const pendingMessageSubmissions = useSessionStore(
|
|
useShallow((state) =>
|
|
agentId
|
|
? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId))
|
|
: EMPTY_MESSAGE_SUBMISSIONS,
|
|
),
|
|
);
|
|
const turnPresentation = useSessionStore(
|
|
useShallow((state) =>
|
|
agentId
|
|
? selectAgentTurnPresentation(state.sessions[serverId], agentId)
|
|
: { isActive: false, isCancelling: false, startedAt: null, turnId: null },
|
|
),
|
|
);
|
|
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
|
const pendingPermissionList = useStoreWithEqualityFn(
|
|
useSessionStore,
|
|
(state) => {
|
|
if (!agentId) {
|
|
return EMPTY_PENDING_PERMISSION_LIST;
|
|
}
|
|
const allPendingPermissions = state.sessions[serverId]?.pendingPermissions;
|
|
if (!allPendingPermissions) {
|
|
return EMPTY_PENDING_PERMISSION_LIST;
|
|
}
|
|
const filtered: PendingPermission[] = [];
|
|
for (const permission of allPendingPermissions.values()) {
|
|
if (permission.agentId === agentId) {
|
|
filtered.push(permission);
|
|
}
|
|
}
|
|
return filtered.length > 0 ? filtered : EMPTY_PENDING_PERMISSION_LIST;
|
|
},
|
|
shallow,
|
|
);
|
|
const pendingPermissions = useMemo(() => {
|
|
if (pendingPermissionList.length === 0) {
|
|
return EMPTY_PENDING_PERMISSIONS;
|
|
}
|
|
return new Map(pendingPermissionList.map((permission) => [permission.key, permission]));
|
|
}, [pendingPermissionList]);
|
|
|
|
return (
|
|
<AgentStreamView
|
|
ref={streamViewRef}
|
|
agentId={agent.id}
|
|
serverId={serverId}
|
|
context={agent}
|
|
streamItems={streamItems}
|
|
pendingPermissions={pendingPermissions}
|
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
|
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
|
toast={toast}
|
|
pendingMessageSubmissions={pendingMessageSubmissions}
|
|
turnPresentation={turnPresentation}
|
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
/>
|
|
);
|
|
});
|
|
|
|
const AgentComposerSection = memo(function AgentComposerSection({
|
|
agentId,
|
|
serverId,
|
|
isPaneFocused,
|
|
isArchivingCurrentAgent,
|
|
archivedAt,
|
|
cwd,
|
|
isSubmitLoading,
|
|
agentInputDraft,
|
|
onAttentionInputFocus,
|
|
onAttentionPromptSend,
|
|
onComposerHeightChange,
|
|
onMessageSent,
|
|
}: {
|
|
agentId?: string;
|
|
serverId: string;
|
|
isPaneFocused: boolean;
|
|
isArchivingCurrentAgent: boolean;
|
|
archivedAt: Date | null;
|
|
cwd: string;
|
|
isSubmitLoading: boolean;
|
|
agentInputDraft: AgentInputDraft;
|
|
onAttentionInputFocus: () => void;
|
|
onAttentionPromptSend: () => void;
|
|
onComposerHeightChange: (height: number) => void;
|
|
onMessageSent: () => void;
|
|
}) {
|
|
if (!agentId) {
|
|
return null;
|
|
}
|
|
if (archivedAt) {
|
|
return <ArchivedAgentCallout serverId={serverId} agentId={agentId} />;
|
|
}
|
|
if (isArchivingCurrentAgent) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<ActiveAgentComposer
|
|
agentId={agentId}
|
|
serverId={serverId}
|
|
isPaneFocused={isPaneFocused}
|
|
cwd={cwd}
|
|
isSubmitLoading={isSubmitLoading}
|
|
agentInputDraft={agentInputDraft}
|
|
onAttentionInputFocus={onAttentionInputFocus}
|
|
onAttentionPromptSend={onAttentionPromptSend}
|
|
onComposerHeightChange={onComposerHeightChange}
|
|
onMessageSent={onMessageSent}
|
|
/>
|
|
);
|
|
});
|
|
|
|
function ActiveAgentComposer({
|
|
agentId,
|
|
serverId,
|
|
isPaneFocused,
|
|
cwd,
|
|
isSubmitLoading,
|
|
agentInputDraft,
|
|
onAttentionInputFocus,
|
|
onAttentionPromptSend,
|
|
onComposerHeightChange,
|
|
onMessageSent,
|
|
}: {
|
|
agentId: string;
|
|
serverId: string;
|
|
isPaneFocused: boolean;
|
|
cwd: string;
|
|
isSubmitLoading: boolean;
|
|
agentInputDraft: AgentInputDraft;
|
|
onAttentionInputFocus: () => void;
|
|
onAttentionPromptSend: () => void;
|
|
onComposerHeightChange: (height: number) => void;
|
|
onMessageSent: () => void;
|
|
}) {
|
|
const insets = useSafeAreaInsets();
|
|
const isCompactFormFactor = useIsCompactFormFactor();
|
|
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
|
COMPACT_FORM_FACTOR_WIDTH,
|
|
{ initialIsBelow: isCompactFormFactor },
|
|
);
|
|
const paneContext = usePaneContext();
|
|
const { workspaceId, tabId, retargetCurrentTab, openTab } = paneContext;
|
|
const { archiveAgent } = useArchiveAgent();
|
|
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
|
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
|
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
|
const subagentRows = useSubagentsForParent({
|
|
serverId,
|
|
parentAgentId: agentId,
|
|
});
|
|
const canDetachSubagents = useSessionStore(
|
|
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
|
|
);
|
|
const handleOpenSubagent = useCallback(
|
|
(subagentId: string) => {
|
|
navigateToAgent({ serverId, agentId: subagentId });
|
|
},
|
|
[serverId],
|
|
);
|
|
const handleOpenProviderSubagent = useCallback(
|
|
(parentAgentId: string, subagentId: string) => {
|
|
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
|
|
},
|
|
[openTab],
|
|
);
|
|
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
|
const handleDetachSubagent = useDetachSubagent({ serverId });
|
|
const archiveFinishedSubagents = useArchiveFinishedSubagents({
|
|
serverId,
|
|
parentAgentId: agentId,
|
|
rows: subagentRows,
|
|
});
|
|
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
|
serverId,
|
|
cwd,
|
|
workspaceId,
|
|
});
|
|
const attachmentScopeKeys = useMemo(
|
|
() => [workspaceAttachmentScopeKey],
|
|
[workspaceAttachmentScopeKey],
|
|
);
|
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
|
const handleOpenWorkspaceAttachment = useCallback(
|
|
(attachment: WorkspaceComposerAttachment) => {
|
|
if (attachment.kind !== "review") {
|
|
return;
|
|
}
|
|
const checkout = {
|
|
serverId,
|
|
cwd: attachment.attachment.cwd,
|
|
isGit: true,
|
|
};
|
|
openFileExplorerForCheckout({
|
|
checkout,
|
|
isCompact: isCompactFormFactor,
|
|
});
|
|
setExplorerTabForCheckout({
|
|
...checkout,
|
|
tab: "changes",
|
|
});
|
|
},
|
|
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
|
);
|
|
|
|
const handleClientSlashCommand = useCallback(
|
|
async (command: ClientSlashCommand) => {
|
|
const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId);
|
|
if (!agent) {
|
|
throw new Error("Agent not found");
|
|
}
|
|
|
|
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
if (workspaceKey) {
|
|
unpinWorkspaceAgent(workspaceKey, agentId);
|
|
hideWorkspaceAgent(workspaceKey, agentId);
|
|
}
|
|
|
|
if (command.kind === "replace-agent-with-draft") {
|
|
retargetCurrentTab({
|
|
kind: "draft",
|
|
draftId: generateDraftId(),
|
|
setup: buildDraftAgentSetup(agent),
|
|
});
|
|
} else if (workspaceKey) {
|
|
closeWorkspaceTab(workspaceKey, tabId);
|
|
}
|
|
|
|
await archiveAgent({ serverId, agentId });
|
|
},
|
|
[
|
|
agentId,
|
|
archiveAgent,
|
|
closeWorkspaceTab,
|
|
hideWorkspaceAgent,
|
|
retargetCurrentTab,
|
|
serverId,
|
|
tabId,
|
|
unpinWorkspaceAgent,
|
|
workspaceId,
|
|
],
|
|
);
|
|
|
|
const { style: composerKeyboardStyle } = useKeyboardShiftStyle({
|
|
mode: "translate",
|
|
});
|
|
|
|
const inputAreaStyle = useMemo(
|
|
() => [
|
|
animatedStaticStyles.inputAreaWrapper,
|
|
{ paddingBottom: insets.bottom },
|
|
composerKeyboardStyle,
|
|
],
|
|
[insets.bottom, composerKeyboardStyle],
|
|
);
|
|
|
|
return (
|
|
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
|
<AgentTaskList serverId={serverId} agentId={agentId} />
|
|
<SubagentsTrack
|
|
rows={subagentRows}
|
|
onOpenSubagent={handleOpenSubagent}
|
|
onOpenProviderSubagent={handleOpenProviderSubagent}
|
|
onArchiveSubagent={handleArchiveSubagent}
|
|
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
|
archiveFinishedStatus={archiveFinishedSubagents.status}
|
|
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
|
/>
|
|
<Composer
|
|
agentId={agentId}
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
externalKeyboardShift
|
|
isPaneFocused={isPaneFocused}
|
|
value={agentInputDraft.text}
|
|
onChangeText={agentInputDraft.editText}
|
|
textReplacementKey={agentInputDraft.textReplacementKey}
|
|
attachments={agentInputDraft.attachments}
|
|
attachmentScopeKeys={attachmentScopeKeys}
|
|
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
|
onChangeAttachments={agentInputDraft.setAttachments}
|
|
cwd={cwd}
|
|
clearDraft={agentInputDraft.clear}
|
|
autoFocus
|
|
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
|
|
isSubmitLoading={isSubmitLoading}
|
|
onAttentionInputFocus={onAttentionInputFocus}
|
|
onAttentionPromptSend={onAttentionPromptSend}
|
|
onComposerHeightChange={onComposerHeightChange}
|
|
onMessageSent={onMessageSent}
|
|
onClientSlashCommand={handleClientSlashCommand}
|
|
isCompactLayout={isCompactComposerLayout}
|
|
/>
|
|
</ReanimatedAnimated.View>
|
|
);
|
|
}
|
|
|
|
function AgentSessionUnavailableState({
|
|
serverLabel,
|
|
connectionStatus,
|
|
lastError,
|
|
isUnknownDaemon = false,
|
|
t,
|
|
}: {
|
|
serverLabel: string;
|
|
connectionStatus: HostRuntimeConnectionStatus;
|
|
lastError: string | null;
|
|
isUnknownDaemon?: boolean;
|
|
t: TFunction;
|
|
}) {
|
|
if (isUnknownDaemon) {
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.errorText}>
|
|
{t("agentPanel.unavailable.unknownHost", { serverLabel })}
|
|
</Text>
|
|
<Text style={styles.statusText}>{t("agentPanel.unavailable.addHost")}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const isConnecting = connectionStatus === "connecting";
|
|
const isPreparingSession = connectionStatus === "online";
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.centerState}>
|
|
{isConnecting || isPreparingSession ? (
|
|
<>
|
|
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
|
<Text style={styles.loadingText}>
|
|
{isPreparingSession
|
|
? t("agentPanel.unavailable.preparingSession", { serverLabel })
|
|
: t("agentPanel.unavailable.connecting", { serverLabel })}
|
|
</Text>
|
|
<Text style={styles.statusText}>
|
|
{isPreparingSession
|
|
? t("agentPanel.unavailable.showSoon")
|
|
: t("agentPanel.unavailable.showWhenOnline")}
|
|
</Text>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text style={styles.offlineTitle}>
|
|
{t("agentPanel.unavailable.reconnectingTo", { serverLabel })}
|
|
</Text>
|
|
<Text style={styles.offlineDescription}>
|
|
{t("agentPanel.unavailable.showAgainWhenReachable")}
|
|
</Text>
|
|
{lastError ? <Text style={styles.offlineDetails}>{lastError}</Text> : null}
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
|
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foregroundMuted,
|
|
});
|
|
const foregroundColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foreground,
|
|
});
|
|
|
|
const animatedStaticStyles = RNStyleSheet.create({
|
|
content: {
|
|
flex: 1,
|
|
},
|
|
inputAreaWrapper: {
|
|
width: "100%",
|
|
},
|
|
});
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
root: {
|
|
flex: 1,
|
|
backgroundColor: theme.colors.surface0,
|
|
},
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: theme.colors.surface0,
|
|
},
|
|
contentContainer: {
|
|
flex: 1,
|
|
overflow: "hidden",
|
|
...(isWeb ? { userSelect: "none" as const } : {}),
|
|
},
|
|
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,
|
|
},
|
|
reconnectingStatusDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: theme.colors.palette.amber[500],
|
|
},
|
|
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",
|
|
},
|
|
}));
|