diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 4337c421f..da150eca8 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -521,8 +521,9 @@ export function AgentStreamView({ }); }, [agentId, pendingPermissionItems.length, streamHead, streamItems]); + const showSyncingIndicator = isSyncingHistory; const showWorkingIndicator = agent.status === "running"; - const showBottomBar = showWorkingIndicator || isVoiceMode; + const showBottomBar = showSyncingIndicator || showWorkingIndicator || isVoiceMode; const listHeaderComponent = useMemo(() => { const hasPermissions = pendingPermissionItems.length > 0; @@ -532,7 +533,11 @@ export function AgentStreamView({ return null; } - const leftContent = showWorkingIndicator ? : null; + const leftContent = showSyncingIndicator + ? + : showWorkingIndicator + ? + : null; return ( @@ -582,6 +587,7 @@ export function AgentStreamView({ ); }, [ pendingPermissionItems, + showSyncingIndicator, showWorkingIndicator, client, streamHead, @@ -864,6 +870,15 @@ function WorkingIndicator() { ); } +function SyncingIndicator() { + return ( + + + Catching up… + + ); +} + // Permission Request Card Component function PermissionRequestCard({ permission, @@ -1298,6 +1313,17 @@ const stylesheet = StyleSheet.create((theme) => ({ borderRadius: 3, backgroundColor: theme.colors.foregroundMuted, }, + syncingIndicator: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[1], + paddingLeft: theme.spacing[2], + }, + syncingIndicatorText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, invertedWrapper: { transform: [{ scaleY: -1 }], width: "100%", diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 7a91fbe35..5fc230894 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -188,6 +188,14 @@ type FileDownloadTokenPayload = Extract< { type: "file_download_token_response" } >["payload"]; +type AgentUpdatePayload = Extract< + SessionOutboundMessage, + { type: "agent_update" } +>["payload"]; + +const getAgentIdFromUpdate = (update: AgentUpdatePayload): string => + update.kind === "remove" ? update.agentId : update.agent.id; + function normalizeAgentSnapshot( snapshot: AgentSnapshotPayload, serverId: string @@ -310,7 +318,6 @@ export function SessionProvider({ const setFileExplorer = useSessionStore((state) => state.setFileExplorer); const clearDraftInput = useDraftStore((state) => state.clearDraftInput); const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); - const getSession = useSessionStore((state) => state.getSession); const updateSessionClient = useSessionStore((state) => state.updateSessionClient); const updateSessionConnection = useSessionStore( (state) => state.updateSessionConnection @@ -354,6 +361,9 @@ export function SessionProvider({ ); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); + const pendingAgentUpdatesRef = useRef>( + new Map() + ); useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { @@ -499,10 +509,115 @@ export function SessionProvider({ // If the client drops mid-initialization, clear pending flags useEffect(() => { if (!connectionSnapshot.isConnected) { + pendingAgentUpdatesRef.current.clear(); setInitializingAgents(serverId, new Map()); } }, [serverId, connectionSnapshot.isConnected, setInitializingAgents]); + const applyAgentUpdatePayload = useCallback( + (update: AgentUpdatePayload) => { + if (update.kind === "remove") { + const agentId = update.agentId; + previousAgentStatusRef.current.delete(agentId); + pendingAgentUpdatesRef.current.delete(agentId); + + setAgents(serverId, (prev) => { + if (!prev.has(agentId)) { + return prev; + } + const next = new Map(prev); + next.delete(agentId); + return next; + }); + + setPendingPermissions(serverId, (prev) => { + if (prev.size === 0) { + return prev; + } + let changed = false; + const next = new Map(prev); + for (const [key, pending] of Array.from(next.entries())) { + if (pending.agentId === agentId) { + next.delete(key); + changed = true; + } + } + return changed ? next : prev; + }); + + setQueuedMessages(serverId, (prev) => { + if (!prev.has(agentId)) { + return prev; + } + const next = new Map(prev); + next.delete(agentId); + return next; + }); + + return; + } + + const agent = normalizeAgentSnapshot(update.agent, serverId); + + console.log("[Session] Agent update:", agent.id, agent.status); + + setAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agent.id, agent); + return next; + }); + + // Update agentLastActivity slice (top-level) + setAgentLastActivity(agent.id, agent.lastActivityAt); + + setPendingPermissions(serverId, (prev) => { + const next = new Map(prev); + for (const [key, pending] of Array.from(next.entries())) { + if (pending.agentId === agent.id) { + next.delete(key); + } + } + for (const request of agent.pendingPermissions) { + const key = derivePendingPermissionKey(agent.id, request); + next.set(key, { key, agentId: agent.id, request }); + } + return next; + }); + + // Flush queued messages when agent transitions from running to not running + const prevStatus = previousAgentStatusRef.current.get(agent.id); + if (prevStatus === "running" && agent.status !== "running") { + const session = useSessionStore.getState().sessions[serverId]; + const queue = session?.queuedMessages.get(agent.id); + if (queue && queue.length > 0) { + const [next, ...rest] = queue; + console.log( + "[Session] Flushing queued message for agent:", + agent.id, + next.text + ); + if (sendAgentMessageRef.current) { + void sendAgentMessageRef.current(agent.id, next.text, next.images); + } + setQueuedMessages(serverId, (prev) => { + const updated = new Map(prev); + updated.set(agent.id, rest); + return updated; + }); + } + } + + previousAgentStatusRef.current.set(agent.id, agent.status); + }, + [ + serverId, + setAgents, + setAgentLastActivity, + setPendingPermissions, + setQueuedMessages, + ] + ); + useEffect(() => { return () => { updateConnectionStatus(serverId, { status: "offline", activeConnection, lastError: null }); @@ -774,97 +889,20 @@ export function SessionProvider({ const unsubAgentUpdate = client.on("agent_update", (message) => { if (message.type !== "agent_update") return; const update = message.payload; + const agentId = getAgentIdFromUpdate(update); + const initKey = getInitKey(serverId, agentId); + const session = useSessionStore.getState().sessions[serverId]; + const isSyncingHistory = + session?.initializingAgents.get(agentId) === true && + Boolean(getInitDeferred(initKey)); - if (update.kind === "remove") { - const agentId = update.agentId; - previousAgentStatusRef.current.delete(agentId); - - setAgents(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - - setPendingPermissions(serverId, (prev) => { - if (prev.size === 0) { - return prev; - } - let changed = false; - const next = new Map(prev); - for (const [key, pending] of Array.from(next.entries())) { - if (pending.agentId === agentId) { - next.delete(key); - changed = true; - } - } - return changed ? next : prev; - }); - - setQueuedMessages(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - + if (isSyncingHistory) { + pendingAgentUpdatesRef.current.set(agentId, update); return; } - const agent = normalizeAgentSnapshot(update.agent, serverId); - - console.log("[Session] Agent update:", agent.id, agent.status); - - setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agent.id, agent); - return next; - }); - - // Update agentLastActivity slice (top-level) - setAgentLastActivity(agent.id, agent.lastActivityAt); - - setPendingPermissions(serverId, (prev) => { - const next = new Map(prev); - for (const [key, pending] of Array.from(next.entries())) { - if (pending.agentId === agent.id) { - next.delete(key); - } - } - for (const request of agent.pendingPermissions) { - const key = derivePendingPermissionKey(agent.id, request); - next.set(key, { key, agentId: agent.id, request }); - } - return next; - }); - - // Flush queued messages when agent transitions from running to not running - const prevStatus = previousAgentStatusRef.current.get(agent.id); - if (prevStatus === "running" && agent.status !== "running") { - const session = useSessionStore.getState().sessions[serverId]; - const queue = session?.queuedMessages.get(agent.id); - if (queue && queue.length > 0) { - const [next, ...rest] = queue; - console.log( - "[Session] Flushing queued message for agent:", - agent.id, - next.text - ); - if (sendAgentMessageRef.current) { - void sendAgentMessageRef.current(agent.id, next.text, next.images); - } - setQueuedMessages(serverId, (prev) => { - const updated = new Map(prev); - updated.set(agent.id, rest); - return updated; - }); - } - } - previousAgentStatusRef.current.set(agent.id, agent.status); + pendingAgentUpdatesRef.current.delete(agentId); + applyAgentUpdatePayload(update); }); const unsubAgentStream = client.on("agent_stream", (message) => { @@ -929,6 +967,8 @@ export function SessionProvider({ timestamp: new Date(timestamp), })) ); + const initKey = getInitKey(serverId, agentId); + const hasInFlightHistorySync = Boolean(getInitDeferred(initKey)); setAgentStreamTail(serverId, (prev) => { const next = new Map(prev); @@ -937,6 +977,12 @@ export function SessionProvider({ }); clearAgentStreamHead(serverId, agentId); + const deferredUpdate = pendingAgentUpdatesRef.current.get(agentId); + pendingAgentUpdatesRef.current.delete(agentId); + if (hasInFlightHistorySync && deferredUpdate) { + applyAgentUpdatePayload(deferredUpdate); + } + setInitializingAgents(serverId, (prev) => { if (prev.get(agentId) !== true) { return prev; @@ -947,7 +993,6 @@ export function SessionProvider({ }); // Resolve the initialization promise (even for empty history) - const initKey = getInitKey(serverId, agentId); resolveInitDeferred(initKey); } ); @@ -1258,6 +1303,7 @@ export function SessionProvider({ } const { agentId } = message.payload; console.log("[Session] Agent deleted:", agentId); + pendingAgentUpdatesRef.current.delete(agentId); setAgents(serverId, (prev) => { if (!prev.has(agentId)) { @@ -1387,9 +1433,9 @@ export function SessionProvider({ setFileExplorer, setHasHydratedAgents, updateConnectionStatus, - getSession, clearDraftInput, notifyAgentAttention, + applyAgentUpdatePayload, ]); const initializeAgent = useCallback( diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index e4722283d..557d3fc9e 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -64,6 +64,7 @@ import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { useToast } from "@/contexts/toast-context"; +import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { DropdownMenu, DropdownMenuContent, @@ -401,6 +402,13 @@ function AgentScreenContent({ }, [resolvedAgentId, setFocusedAgentId]); const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false; + const isHistorySyncing = useMemo(() => { + if (!resolvedAgentId || !isInitializing) { + return false; + } + const initKey = getInitKey(serverId, resolvedAgentId); + return Boolean(getInitDeferred(initKey)); + }, [resolvedAgentId, isInitializing, serverId]); const optimisticStreamItems = useMemo(() => { if (!isPendingCreateForRoute || !pendingCreate) { @@ -840,7 +848,7 @@ function AgentScreenContent({ shouldUseOptimisticStream ? mergedStreamItems : streamItems } pendingPermissions={pendingPermissions} - isSyncingHistory={isInitializing && !shouldUseOptimisticStream} + isSyncingHistory={isHistorySyncing && !shouldUseOptimisticStream} /> diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-debug.ts b/packages/server/src/server/daemon-e2e/checkout-diff-debug.ts new file mode 100644 index 000000000..8e6d53d2e --- /dev/null +++ b/packages/server/src/server/daemon-e2e/checkout-diff-debug.ts @@ -0,0 +1,186 @@ +#!/usr/bin/env npx tsx +/** + * Ad-hoc checkout diff debugger. + * + * Usage: + * npx tsx packages/server/src/server/daemon-e2e/checkout-diff-debug.ts + * npx tsx packages/server/src/server/daemon-e2e/checkout-diff-debug.ts --agent + * npx tsx packages/server/src/server/daemon-e2e/checkout-diff-debug.ts --cwd + * npx tsx packages/server/src/server/daemon-e2e/checkout-diff-debug.ts --limit 3 + * + * Optional env: + * PASEO_LISTEN=127.0.0.1:6767 + */ + +import os from "node:os"; +import { DaemonClient } from "../../client/daemon-client.js"; + +type CliArgs = { + agentId?: string; + cwd?: string; + limit: number; +}; + +function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = { limit: 5 }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--agent") { + const value = argv[i + 1]; + if (value) { + args.agentId = value; + i += 1; + } + continue; + } + if (token === "--cwd") { + const value = argv[i + 1]; + if (value) { + args.cwd = value; + i += 1; + } + continue; + } + if (token === "--limit") { + const value = Number.parseInt(argv[i + 1] ?? "", 10); + if (!Number.isNaN(value) && value > 0) { + args.limit = value; + i += 1; + } + } + } + return args; +} + +function fmtMs(ms: number): string { + return `${ms.toLocaleString()}ms`; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const listen = process.env.PASEO_LISTEN ?? "127.0.0.1:6767"; + const url = `ws://${listen}/ws`; + const home = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`; + + console.log("Checkout Diff Debugger"); + console.log(`daemon=${url}`); + console.log(`PASEO_HOME=${home}`); + console.log( + `filters agent=${args.agentId ?? "-"} cwd=${args.cwd ?? "-"} limit=${args.limit}` + ); + console.log(""); + + const client = new DaemonClient({ + url, + reconnect: { enabled: false }, + }); + + client.on("checkout_status_response", (message) => { + if (message.type !== "checkout_status_response") return; + const payload = message.payload; + console.log( + `[raw] checkout_status_response requestId=${payload.requestId} cwd=${payload.cwd} isGit=${payload.isGit}` + ); + }); + + client.on("checkout_diff_response", (message) => { + if (message.type !== "checkout_diff_response") return; + const payload = message.payload; + console.log( + `[raw] checkout_diff_response requestId=${payload.requestId} cwd=${payload.cwd} files=${payload.files.length} error=${payload.error ? "yes" : "no"}` + ); + }); + + client.on("rpc_error", (message) => { + if (message.type !== "rpc_error") return; + const payload = message.payload; + console.log( + `[raw] rpc_error requestId=${payload.requestId} requestType=${payload.requestType} code=${payload.code ?? "none"}` + ); + }); + + try { + await client.connect(); + const ping = await client.ping({ timeoutMs: 3000 }); + console.log(`ping=${fmtMs(ping.rttMs)}`); + + const snapshots = await client.fetchAgents({ filter: { labels: { ui: "true" } } }); + const candidates = snapshots + .filter((snapshot) => !args.agentId || snapshot.id === args.agentId) + .map((snapshot) => ({ + id: snapshot.id, + title: snapshot.title ?? "(untitled)", + cwd: snapshot.cwd, + updatedAt: snapshot.updatedAt, + })) + .filter((item) => !args.cwd || item.cwd === args.cwd) + .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); + + const targets = (args.cwd + ? [{ id: "(manual)", title: "(manual)", cwd: args.cwd, updatedAt: new Date().toISOString() }] + : candidates + ) + .slice(0, args.limit) + .filter((item, index, list) => list.findIndex((v) => v.cwd === item.cwd) === index); + + if (targets.length === 0) { + console.log("No matching agents/cwds found."); + return; + } + + console.log(`Testing ${targets.length} cwd target(s)\n`); + + for (const target of targets) { + console.log(`--- ${target.cwd}`); + console.log(`agent=${target.id} title=${target.title}`); + + const statusStart = Date.now(); + let statusPayload: Awaited>; + try { + statusPayload = await client.getCheckoutStatus(target.cwd); + console.log( + `status: ok ${fmtMs(Date.now() - statusStart)} isGit=${statusPayload.isGit} branch=${statusPayload.currentBranch ?? "-"} dirty=${statusPayload.isDirty ?? "-"} baseRef=${statusPayload.baseRef ?? "-"}` + ); + if (statusPayload.error) { + console.log(`status.error=${statusPayload.error.message}`); + } + } catch (error) { + console.log(`status: FAIL ${fmtMs(Date.now() - statusStart)} ${String(error)}`); + console.log(""); + continue; + } + + if (!statusPayload.isGit) { + console.log("diff: skipped (not a git repo)\n"); + continue; + } + + const compareMode = statusPayload.isDirty ? "uncommitted" : "base"; + const diffStart = Date.now(); + try { + const diff = await client.getCheckoutDiff(target.cwd, { + mode: compareMode, + baseRef: statusPayload.baseRef ?? undefined, + }); + const diffDuration = Date.now() - diffStart; + console.log( + `diff: ok ${fmtMs(diffDuration)} mode=${compareMode} files=${diff.files.length} error=${diff.error ? "yes" : "no"}` + ); + if (diff.error) { + console.log(`diff.error=${diff.error.message}`); + } + } catch (error) { + console.log(`diff: FAIL ${fmtMs(Date.now() - diffStart)} ${String(error)}`); + } + console.log(""); + } + } finally { + await client.close(); + } +} + +void main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); +