mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
Paseo subagent rows in the subagents list had an empty info strip: the row only rendered the daemon-provided subtitle, which only provider subagents carry. The row now uses the same facts line an agent's sheet shows when it opens without its composer, so both surfaces resolve model, thinking, mode, and context the way the composer's controls do. The facts sit under the title instead of beside it, so a long task name keeps the row's width instead of fighting the strip for it.
1882 lines
58 KiB
TypeScript
1882 lines
58 KiB
TypeScript
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
|
import { Button } from "@/components/ui/button";
|
|
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,
|
|
type ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
useSyncExternalStore,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
|
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 { ComposerDock } from "@/composer/dock";
|
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
|
import { RetainedChatContent } from "./retained-chat-content";
|
|
import { Composer } from "@/composer";
|
|
import {
|
|
useComposerEditingSession,
|
|
useComposerFullscreen,
|
|
type ComposerEditingSession,
|
|
} from "@/composer/editing-session";
|
|
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
|
import {
|
|
resolveComposerTrackControlClearance,
|
|
resolveComposerTrackTailClearance,
|
|
} from "@/composer/pill-styles";
|
|
import { getActiveMessageSubmissions } from "@/composer/submission/model";
|
|
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
|
import { useProviderIcon } from "@/components/provider-icons";
|
|
import { 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 { isNative, isWeb } from "@/constants/platform";
|
|
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
|
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
|
|
import {
|
|
type AgentScreenAgent,
|
|
type AgentScreenContinuity,
|
|
type AgentScreenMissingState,
|
|
type AgentScreenViewState,
|
|
useAgentScreenStateMachine,
|
|
type AgentScreenReadySyncState,
|
|
} from "@/hooks/use-agent-screen-state-machine";
|
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
|
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
|
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
|
|
import { TimelineSyncStatus } from "@/timeline/sync-status";
|
|
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
|
import { useAgentFactsLine } from "@/panels/agent-facts-line";
|
|
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
|
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
|
import { RenderProfile } from "@/utils/render-profiler";
|
|
import { useHasPluginComposerPills } from "@/plugins";
|
|
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
|
import {
|
|
type HostRuntimeConnectionStatus,
|
|
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 { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
|
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
|
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
|
import {
|
|
selectAgentTimelineState,
|
|
selectAgentTurnPresentation,
|
|
type Agent,
|
|
useSessionStore,
|
|
} from "@/stores/session-store";
|
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
|
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
|
import { useSettings } from "@/hooks/use-settings";
|
|
import type { Theme } from "@/styles/theme";
|
|
import type { PendingPermission } from "@/types/shared";
|
|
import type { StreamItem, TodoEntry } from "@/types/stream";
|
|
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
|
|
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
|
|
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 { 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function readViewedTimelineError(input: {
|
|
agentId: string | undefined;
|
|
status: ViewedTimelineStatus;
|
|
sync: ViewedTimelineUiBridge | null;
|
|
}): string | null {
|
|
if (!input.agentId || input.status !== "error" || !input.sync) return null;
|
|
return input.sync.getAgentTimelineError(input.agentId);
|
|
}
|
|
|
|
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 AgentLoadErrorView({ message, onRetry }: { message: string; onRetry: () => void }) {
|
|
const { t } = useTranslation();
|
|
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}>{message}</Text>
|
|
<View style={styles.errorAction}>
|
|
<Button size="sm" variant="secondary" onPress={onRetry} testID="agent-load-error-retry">
|
|
{t("common.actions.retry")}
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function renderChatAgentNonReadyView(args: {
|
|
viewState: AgentScreenViewState;
|
|
effectiveAgent: AgentScreenAgent | null;
|
|
t: TFunction;
|
|
onRetryLoad: () => void;
|
|
}): React.ReactElement | null {
|
|
const { viewState, effectiveAgent, t, onRetryLoad } = 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 <AgentLoadErrorView message={viewState.message} onRetry={onRetryLoad} />;
|
|
}
|
|
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)) {
|
|
getHostRuntimeStore().acceptAgentSnapshot(input.serverId, hydrated);
|
|
} 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 = useProviderIcon(provider, context.serverId);
|
|
|
|
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 (
|
|
<RetainedChatContent>
|
|
<AgentPanelContent
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
agentId={target.agentId}
|
|
isPaneFocused={isInteractive}
|
|
onOpenWorkspaceFile={openFileInWorkspace}
|
|
/>
|
|
</RetainedChatContent>
|
|
);
|
|
}
|
|
|
|
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 });
|
|
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
|
|
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 = definePanel("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}
|
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function AgentPanelBody({
|
|
serverId,
|
|
workspaceId,
|
|
agentId,
|
|
isPaneFocused,
|
|
client,
|
|
isConnected,
|
|
onOpenWorkspaceFile,
|
|
}: {
|
|
serverId: string;
|
|
workspaceId: string;
|
|
agentId?: string;
|
|
isPaneFocused: boolean;
|
|
client: ReturnType<typeof useHostRuntimeClient>;
|
|
isConnected: boolean;
|
|
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 retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []);
|
|
|
|
useEffect(() => {
|
|
lookupAttemptTokenRef.current += 1;
|
|
setLookupState({ tag: "idle" });
|
|
}, [agentId, serverId]);
|
|
|
|
useEffect(() => {
|
|
if (!agentId) {
|
|
return;
|
|
}
|
|
if (agentState.id) {
|
|
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) {
|
|
setLookupState({
|
|
tag: "not_found",
|
|
message: `Agent not found: ${agentId}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
storeFetchedAgentDetail({ serverId, result });
|
|
setLookupState({ tag: "idle" });
|
|
return;
|
|
})
|
|
.catch((error) => {
|
|
if (attemptToken !== lookupAttemptTokenRef.current) {
|
|
return;
|
|
}
|
|
const message = toErrorMessage(error);
|
|
if (isNotFoundErrorMessage(message)) {
|
|
setLookupState({ tag: "not_found", message });
|
|
return;
|
|
}
|
|
setLookupState({ tag: "error", message });
|
|
});
|
|
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
|
|
|
|
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 <AgentLoadErrorView message={lookupState.message} onRetry={retryAgentLookup} />;
|
|
}
|
|
|
|
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}
|
|
workspaceId={workspaceId}
|
|
agentId={agentId}
|
|
isPaneFocused={isPaneFocused}
|
|
client={client}
|
|
isConnected={isConnected}
|
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ChatAgentContent({
|
|
serverId,
|
|
workspaceId,
|
|
agentId,
|
|
isPaneFocused,
|
|
client,
|
|
isConnected,
|
|
onOpenWorkspaceFile,
|
|
}: {
|
|
serverId: string;
|
|
workspaceId: string;
|
|
agentId?: string;
|
|
isPaneFocused: boolean;
|
|
client: ReturnType<typeof useHostRuntimeClient>;
|
|
isConnected: boolean;
|
|
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 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 visibilityCatchUpError = readViewedTimelineError({
|
|
agentId,
|
|
status: visibilityCatchUpStatus,
|
|
sync: viewedTimelineSync,
|
|
});
|
|
const hasActiveCreateHandoff = useCreateFlowStore((state) =>
|
|
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
|
|
);
|
|
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
|
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 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,
|
|
visibilityCatchUpError,
|
|
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]);
|
|
|
|
const handleRewindComplete = useCallback(() => {
|
|
streamViewRef.current?.scrollToBottom("rewind");
|
|
}, []);
|
|
|
|
const retryAgentLoad = useCallback(() => {
|
|
setMissingAgentState({ kind: "idle" });
|
|
if (!agentId || !viewedTimelineSync) return;
|
|
viewedTimelineSync.retryVisibleAgentTimeline(agentId);
|
|
}, [agentId, viewedTimelineSync]);
|
|
|
|
const retryTimelineSync = useCallback(() => {
|
|
if (!agentId || !viewedTimelineSync) return;
|
|
viewedTimelineSync.retryVisibleAgentTimeline(agentId);
|
|
}, [agentId, viewedTimelineSync]);
|
|
|
|
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 ||
|
|
!viewedTimelineSync ||
|
|
!isPaneVisible ||
|
|
!isConnected ||
|
|
!hasSession ||
|
|
visibilityCatchUpStatus !== "ready"
|
|
) {
|
|
return;
|
|
}
|
|
if (
|
|
missingAgentState.kind === "resolving" ||
|
|
missingAgentState.kind === "not_found" ||
|
|
missingAgentState.kind === "error"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setMissingAgentState({ kind: "resolving" });
|
|
const attemptToken = ++initAttemptTokenRef.current;
|
|
|
|
Promise.resolve()
|
|
.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,
|
|
hasSession,
|
|
isConnected,
|
|
isPaneVisible,
|
|
missingAgentState.kind,
|
|
serverId,
|
|
viewedTimelineSync,
|
|
visibilityCatchUpStatus,
|
|
]);
|
|
|
|
const nonReadyView = renderChatAgentNonReadyView({
|
|
viewState,
|
|
effectiveAgent,
|
|
t,
|
|
onRetryLoad: retryAgentLoad,
|
|
});
|
|
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";
|
|
const isRetryingHistorySync =
|
|
viewState.tag === "ready" &&
|
|
viewState.sync.status === "sync_error" &&
|
|
viewState.sync.isRetrying;
|
|
|
|
return (
|
|
<ChatAgentReadyContent
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
agentId={agentId}
|
|
isPaneFocused={isPaneFocused}
|
|
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
|
agentState={agentState}
|
|
effectiveAgent={effectiveAgent}
|
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
|
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
|
toastApi={toastApi}
|
|
toast={toastState}
|
|
dismiss={dismissToast}
|
|
streamViewRef={streamViewRef}
|
|
handleComposerHeightChange={handleComposerHeightChange}
|
|
handleMessageSent={handleMessageSent}
|
|
handleRewindComplete={handleRewindComplete}
|
|
timelineSync={isPaneVisible && viewState.tag === "ready" ? viewState.sync : null}
|
|
showHistorySyncOverlay={showHistorySyncOverlay}
|
|
showHistorySyncError={showHistorySyncError}
|
|
isRetryingHistorySync={isRetryingHistorySync}
|
|
cwd={agentCwd}
|
|
retryTimelineSync={retryTimelineSync}
|
|
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
|
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|
serverId,
|
|
workspaceId,
|
|
agentId,
|
|
isPaneFocused,
|
|
isArchivingCurrentAgent,
|
|
agentState,
|
|
effectiveAgent,
|
|
routeBottomAnchorRequest,
|
|
hasAppliedAuthoritativeHistory,
|
|
toastApi,
|
|
toast,
|
|
dismiss,
|
|
streamViewRef,
|
|
handleComposerHeightChange,
|
|
handleMessageSent,
|
|
handleRewindComplete,
|
|
timelineSync,
|
|
showHistorySyncOverlay,
|
|
showHistorySyncError,
|
|
isRetryingHistorySync,
|
|
retryTimelineSync,
|
|
cwd,
|
|
onAttentionInputFocus,
|
|
onAttentionPromptSend,
|
|
onOpenWorkspaceFile,
|
|
}: {
|
|
serverId: string;
|
|
workspaceId: 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>;
|
|
handleComposerHeightChange: (height: number) => void;
|
|
handleMessageSent: () => void;
|
|
handleRewindComplete: () => void;
|
|
timelineSync: AgentScreenReadySyncState | null;
|
|
showHistorySyncOverlay: boolean;
|
|
showHistorySyncError: boolean;
|
|
isRetryingHistorySync: boolean;
|
|
retryTimelineSync: () => void;
|
|
cwd: string;
|
|
onAttentionInputFocus: () => void;
|
|
onAttentionPromptSend: () => void;
|
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
// The workspace sheet is for looking; Open as tab brings the agent back with its composer.
|
|
const showsComposer = usePaneContext().host !== "sheet";
|
|
// The dock lays out fullscreen for the same session the composer edits in.
|
|
const composerEditingSession = useComposerEditingSession("chat");
|
|
const isComposerFullscreen = useComposerFullscreen(composerEditingSession);
|
|
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
|
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
|
state.sessions[serverId]?.agentTasks.get(agentId),
|
|
);
|
|
const archiveFinishedSubagents = useArchiveFinishedSubagents({
|
|
serverId,
|
|
parentAgentId: agentId,
|
|
rows: subagentRows,
|
|
});
|
|
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
|
|
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
|
const hasVisibleAgentTracks = hasAgentTracks({
|
|
subagentRows,
|
|
tasks,
|
|
archiveFinishedStatus: archiveFinishedSubagents.status,
|
|
hasPluginComposerPills,
|
|
});
|
|
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 {
|
|
textSource,
|
|
editText,
|
|
replaceText,
|
|
textReplacement,
|
|
attachments,
|
|
setAttachments,
|
|
clear,
|
|
isHydrated,
|
|
attachmentFocusRequestId,
|
|
composerState,
|
|
} = rawAgentInputDraft;
|
|
const agentInputDraft = useMemo(
|
|
(): AgentInputDraft => ({
|
|
textSource,
|
|
editText,
|
|
replaceText,
|
|
textReplacement,
|
|
attachments,
|
|
setAttachments,
|
|
clear,
|
|
isHydrated,
|
|
attachmentFocusRequestId,
|
|
composerState,
|
|
}),
|
|
[
|
|
textSource,
|
|
editText,
|
|
replaceText,
|
|
textReplacement,
|
|
attachments,
|
|
setAttachments,
|
|
clear,
|
|
isHydrated,
|
|
attachmentFocusRequestId,
|
|
composerState,
|
|
],
|
|
);
|
|
const composerSection = showsComposer ? (
|
|
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
|
<AgentComposerSection
|
|
agentId={agentId}
|
|
serverId={serverId}
|
|
isPaneFocused={isPaneFocused}
|
|
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
|
archivedAt={agentState.archivedAt}
|
|
cwd={cwd}
|
|
isSubmitLoading={false}
|
|
agentInputDraft={agentInputDraft}
|
|
editingSession={composerEditingSession}
|
|
onAttentionInputFocus={onAttentionInputFocus}
|
|
onAttentionPromptSend={onAttentionPromptSend}
|
|
onComposerHeightChange={handleComposerHeightChange}
|
|
onMessageSent={handleMessageSent}
|
|
/>
|
|
</RenderProfile>
|
|
) : null;
|
|
const streamContent = (
|
|
<View style={animatedStaticStyles.content}>
|
|
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
|
|
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
|
<AgentStreamSection
|
|
streamViewRef={streamViewRef}
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
agentId={agentId}
|
|
agent={effectiveAgent}
|
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
|
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
|
hasActiveComposer={hasActiveComposer}
|
|
hasVisibleAgentTracks={hasVisibleAgentTracks}
|
|
toast={toastApi}
|
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
/>
|
|
</RenderProfile>
|
|
{hasActiveComposer ? (
|
|
<AgentTracks
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
agentId={agentId}
|
|
cwd={cwd}
|
|
subagentRows={subagentRows}
|
|
tasks={tasks}
|
|
archiveFinishedStatus={archiveFinishedSubagents.status}
|
|
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
|
hasPluginComposerPills={hasPluginComposerPills}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
|
|
const dockContent = (
|
|
<View style={styles.contentContainer}>
|
|
{streamContent}
|
|
|
|
{showHistorySyncError ? (
|
|
<TimelineSyncErrorCallout isRetrying={isRetryingHistorySync} onRetry={retryTimelineSync} />
|
|
) : null}
|
|
</View>
|
|
);
|
|
|
|
const dockOverlay = (
|
|
<>
|
|
{showHistorySyncOverlay ? (
|
|
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
|
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
|
</View>
|
|
) : null}
|
|
|
|
<TimelineSyncStatus sync={timelineSync} toast={toast} onDismiss={dismiss} />
|
|
</>
|
|
);
|
|
|
|
const dock = (
|
|
<ChatSurface disabled={isArchivingCurrentAgent} fullscreen={isComposerFullscreen}>
|
|
{dockContent}
|
|
{composerSection}
|
|
{dockOverlay}
|
|
</ChatSurface>
|
|
);
|
|
|
|
return (
|
|
<RewindComposerRestoreProvider
|
|
textSource={agentInputDraft.textSource}
|
|
setText={agentInputDraft.replaceText}
|
|
onRewindComplete={handleRewindComplete}
|
|
>
|
|
<View style={styles.root}>
|
|
{dock}
|
|
|
|
{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>
|
|
);
|
|
});
|
|
|
|
function ChatSurface({
|
|
children,
|
|
disabled,
|
|
fullscreen,
|
|
}: {
|
|
children: [ReactNode, ReactNode, ReactNode];
|
|
disabled: boolean;
|
|
fullscreen: boolean;
|
|
}) {
|
|
return (
|
|
<FileDropZone style={styles.container} disabled={disabled}>
|
|
<ComposerDock fullscreen={fullscreen}>{children}</ComposerDock>
|
|
</FileDropZone>
|
|
);
|
|
}
|
|
|
|
function TimelineSyncErrorCallout({
|
|
isRetrying,
|
|
onRetry,
|
|
}: {
|
|
isRetrying: boolean;
|
|
onRetry: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<View style={styles.timelineSyncCalloutRail}>
|
|
<View style={styles.timelineSyncCalloutContent}>
|
|
<View style={styles.timelineSyncCallout} testID="agent-timeline-sync-error">
|
|
<Text style={styles.timelineSyncCalloutText}>
|
|
{t("agentPanel.states.timelineSyncFailed")}
|
|
</Text>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onPress={onRetry}
|
|
disabled={isRetrying}
|
|
testID="agent-timeline-sync-retry"
|
|
>
|
|
{isRetrying ? t("agentPanel.states.timelineSyncRetrying") : t("common.actions.retry")}
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* What the composer's controls and context meter would show, as one line, for an agent shown
|
|
* without its composer.
|
|
*/
|
|
function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
|
|
const text = useAgentFactsLine(serverId, agentId);
|
|
return text ? <PaneSubtitle text={text} testID="agent-pane-subtitle" /> : null;
|
|
}
|
|
|
|
const AgentStreamSection = memo(function AgentStreamSection({
|
|
streamViewRef,
|
|
serverId,
|
|
workspaceId,
|
|
agentId,
|
|
agent,
|
|
routeBottomAnchorRequest,
|
|
hasAppliedAuthoritativeHistory,
|
|
hasActiveComposer,
|
|
hasVisibleAgentTracks,
|
|
toast,
|
|
onOpenWorkspaceFile,
|
|
}: {
|
|
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
|
serverId: string;
|
|
workspaceId: string;
|
|
agentId?: string;
|
|
agent: AgentScreenAgent;
|
|
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
|
hasAppliedAuthoritativeHistory: boolean;
|
|
hasActiveComposer: boolean;
|
|
hasVisibleAgentTracks: boolean;
|
|
toast: ReturnType<typeof useToastHost>["api"];
|
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
|
}) {
|
|
const isCompactFormFactor = useIsCompactFormFactor();
|
|
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
|
const hasVisibleComposerTracks =
|
|
hasActiveComposer && (hasVisibleAgentTracks || hasWorkspaceDiffStat);
|
|
const bottomOverlayTailClearance = hasVisibleComposerTracks
|
|
? resolveComposerTrackTailClearance(isCompactFormFactor)
|
|
: 0;
|
|
const bottomOverlayControlClearance = hasVisibleComposerTracks
|
|
? resolveComposerTrackControlClearance(isCompactFormFactor)
|
|
: 0;
|
|
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}
|
|
bottomOverlayTailClearance={bottomOverlayTailClearance}
|
|
bottomOverlayControlClearance={bottomOverlayControlClearance}
|
|
toast={toast}
|
|
pendingMessageSubmissions={pendingMessageSubmissions}
|
|
turnPresentation={turnPresentation}
|
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
/>
|
|
);
|
|
});
|
|
|
|
const AgentComposerSection = memo(function AgentComposerSection({
|
|
agentId,
|
|
serverId,
|
|
isPaneFocused,
|
|
isArchivingCurrentAgent,
|
|
archivedAt,
|
|
cwd,
|
|
isSubmitLoading,
|
|
agentInputDraft,
|
|
editingSession,
|
|
onAttentionInputFocus,
|
|
onAttentionPromptSend,
|
|
onComposerHeightChange,
|
|
onMessageSent,
|
|
}: {
|
|
agentId?: string;
|
|
serverId: string;
|
|
isPaneFocused: boolean;
|
|
isArchivingCurrentAgent: boolean;
|
|
archivedAt: Date | null;
|
|
cwd: string;
|
|
isSubmitLoading: boolean;
|
|
agentInputDraft: AgentInputDraft;
|
|
editingSession: ComposerEditingSession;
|
|
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}
|
|
editingSession={editingSession}
|
|
onAttentionInputFocus={onAttentionInputFocus}
|
|
onAttentionPromptSend={onAttentionPromptSend}
|
|
onComposerHeightChange={onComposerHeightChange}
|
|
onMessageSent={onMessageSent}
|
|
/>
|
|
);
|
|
});
|
|
|
|
function ActiveAgentComposer({
|
|
agentId,
|
|
serverId,
|
|
isPaneFocused,
|
|
cwd,
|
|
isSubmitLoading,
|
|
agentInputDraft,
|
|
editingSession,
|
|
onAttentionInputFocus,
|
|
onAttentionPromptSend,
|
|
onComposerHeightChange,
|
|
onMessageSent,
|
|
}: {
|
|
agentId: string;
|
|
serverId: string;
|
|
isPaneFocused: boolean;
|
|
cwd: string;
|
|
isSubmitLoading: boolean;
|
|
agentInputDraft: AgentInputDraft;
|
|
editingSession: ComposerEditingSession;
|
|
onAttentionInputFocus: () => void;
|
|
onAttentionPromptSend: () => void;
|
|
onComposerHeightChange: (height: number) => void;
|
|
onMessageSent: () => void;
|
|
}) {
|
|
const isCompactFormFactor = useIsCompactFormFactor();
|
|
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
|
COMPACT_FORM_FACTOR_WIDTH,
|
|
{ initialIsBelow: isCompactFormFactor },
|
|
);
|
|
const paneContext = usePaneContext();
|
|
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
|
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
|
|
const { archiveAgent } = useArchiveAgent();
|
|
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
|
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
|
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
|
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
|
serverId,
|
|
cwd,
|
|
workspaceId,
|
|
});
|
|
const attachmentScopeKeys = useMemo(
|
|
() => [workspaceAttachmentScopeKey],
|
|
[workspaceAttachmentScopeKey],
|
|
);
|
|
const handleOpenWorkspaceAttachment = useCallback(
|
|
(attachment: WorkspaceComposerAttachment) => {
|
|
if (attachment.kind !== "review") {
|
|
return;
|
|
}
|
|
openWorkspaceChanges({
|
|
isCompact: isCompactFormFactor,
|
|
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
|
checkout: { serverId, cwd, isGit: true },
|
|
preferences: openInSidePane,
|
|
});
|
|
},
|
|
[cwd, isCompactFormFactor, openInSidePane, serverId, workspaceId],
|
|
);
|
|
|
|
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,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
|
|
<Composer
|
|
agentId={agentId}
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
blurOnSubmit={isNative}
|
|
isPaneFocused={isPaneFocused}
|
|
textSource={agentInputDraft.textSource}
|
|
onChangeText={agentInputDraft.editText}
|
|
textReplacement={agentInputDraft.textReplacement}
|
|
attachments={agentInputDraft.attachments}
|
|
attachmentScopeKeys={attachmentScopeKeys}
|
|
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
|
onChangeAttachments={agentInputDraft.setAttachments}
|
|
cwd={cwd}
|
|
clearDraft={agentInputDraft.clear}
|
|
editingSession={editingSession}
|
|
autoFocus
|
|
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
|
|
isSubmitLoading={isSubmitLoading}
|
|
onAttentionInputFocus={onAttentionInputFocus}
|
|
onAttentionPromptSend={onAttentionPromptSend}
|
|
onComposerHeightChange={onComposerHeightChange}
|
|
onMessageSent={onMessageSent}
|
|
onClientSlashCommand={handleClientSlashCommand}
|
|
isCompactLayout={isCompactComposerLayout}
|
|
/>
|
|
</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%",
|
|
flexShrink: 1,
|
|
// Content-sized inline; fills the dock's fullscreen slot.
|
|
flexGrow: 1,
|
|
},
|
|
});
|
|
|
|
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 } : {}),
|
|
},
|
|
timelineSyncCalloutRail: {
|
|
width: "100%",
|
|
alignItems: "center",
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingTop: theme.spacing[2],
|
|
},
|
|
timelineSyncCalloutContent: {
|
|
width: "100%",
|
|
maxWidth: theme.contentMaxWidth,
|
|
},
|
|
timelineSyncCallout: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[3],
|
|
backgroundColor: theme.colors.surface1,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.statusDanger,
|
|
borderRadius: theme.borderRadius["2xl"],
|
|
paddingVertical: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[4],
|
|
},
|
|
timelineSyncCalloutText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.base,
|
|
},
|
|
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.base,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
color: theme.colors.foreground,
|
|
textAlign: "center",
|
|
},
|
|
archivingSubtitle: {
|
|
fontSize: theme.fontSize.base,
|
|
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.base,
|
|
color: theme.colors.foregroundMuted,
|
|
textAlign: "center",
|
|
},
|
|
errorAction: {
|
|
marginTop: theme.spacing[4],
|
|
},
|
|
statusText: {
|
|
marginTop: theme.spacing[2],
|
|
textAlign: "center",
|
|
fontSize: theme.fontSize.base,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
offlineTitle: {
|
|
fontSize: theme.fontSize.base,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
color: theme.colors.foreground,
|
|
textAlign: "center",
|
|
},
|
|
offlineDescription: {
|
|
fontSize: theme.fontSize.base,
|
|
color: theme.colors.foregroundMuted,
|
|
textAlign: "center",
|
|
},
|
|
offlineDetails: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
textAlign: "center",
|
|
},
|
|
}));
|