From 4aa8800582e6e80622aee94a3b07513ae9ab3db3 Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Fri, 22 May 2026 17:43:16 -0300 Subject: [PATCH 1/3] polish initial loading state when opening tasks with long threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When opening an existing task with a long thread, the UI used to show a bare 32px spinner with no copy (local), or briefly render just the initial user prompt with no indication more is loading (cloud). Once messages trickled in, there was no signal that the agent was still reconnecting. Three coordinated UX changes: - Extract a shared InitializingSplash (hedgehog + 2s reveal + heading + subtitle) so cloud and local share one source of truth. - New LocalInitializingView replaces the bare local spinner with the same polished pattern. Copy is keyed on whether we're resuming an existing session ("Loading your conversation…") or spinning up a new one ("Getting things ready…"). - New ConversationLoadingHint pill sits above the input and surfaces the in-between states: "Reconnecting to your agent…" while local logs have loaded but the agent process is still spawning, and "Loading more messages…" while a cloud run is queued/in_progress and the first event has arrived but more are still being polled. Hidden during active generation since SessionFooter already covers that. Generated-By: PostHog Code Task-Id: 743a524d-7374-453b-91b4-e5ab43342707 --- .../components/CommandCenterSessionView.tsx | 2 + .../components/CloudInitializingView.tsx | 49 +--------------- .../components/ConversationLoadingHint.tsx | 54 ++++++++++++++++++ .../sessions/components/ConversationView.tsx | 8 +++ .../components/InitializingSplash.tsx | 57 +++++++++++++++++++ .../components/LocalInitializingView.tsx | 20 +++++++ .../sessions/components/SessionView.tsx | 11 ++-- .../sessions/hooks/useSessionViewState.ts | 1 + .../task-detail/components/TaskLogsPanel.tsx | 2 + 9 files changed, 150 insertions(+), 54 deletions(-) create mode 100644 apps/code/src/renderer/features/sessions/components/ConversationLoadingHint.tsx create mode 100644 apps/code/src/renderer/features/sessions/components/InitializingSplash.tsx create mode 100644 apps/code/src/renderer/features/sessions/components/LocalInitializingView.tsx diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx b/apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx index 44791e68b..7a7ac3956 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx +++ b/apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx @@ -30,6 +30,7 @@ export function CommandCenterSessionView({ isPromptPending, promptStartedAt, isInitializing, + isResumingExistingSession, cloudBranch, cloudStatus, errorTitle, @@ -70,6 +71,7 @@ export function CommandCenterSessionView({ onRetry={handleRetry} onNewSession={isCloud ? undefined : handleNewSession} isInitializing={isInitializing} + isResumingExistingSession={isResumingExistingSession} isCloud={isCloud} cloudStatus={cloudStatus} compact diff --git a/apps/code/src/renderer/features/sessions/components/CloudInitializingView.tsx b/apps/code/src/renderer/features/sessions/components/CloudInitializingView.tsx index 101900c6c..92185dffe 100644 --- a/apps/code/src/renderer/features/sessions/components/CloudInitializingView.tsx +++ b/apps/code/src/renderer/features/sessions/components/CloudInitializingView.tsx @@ -1,15 +1,10 @@ -import { Spinner } from "@phosphor-icons/react"; -import { Flex, Text } from "@radix-ui/themes"; -import zenHedgehog from "@renderer/assets/images/zen.png"; import type { TaskRunStatus } from "@shared/types"; -import { useEffect, useState } from "react"; +import { InitializingSplash } from "./InitializingSplash"; interface CloudInitializingViewProps { cloudStatus: TaskRunStatus | null; } -const REVEAL_DELAY_MS = 2000; - function copyFor(cloudStatus: TaskRunStatus | null): { heading: string; subtitle: string; @@ -37,45 +32,5 @@ export function CloudInitializingView({ cloudStatus, }: CloudInitializingViewProps) { const { heading, subtitle } = copyFor(cloudStatus); - - const [revealed, setRevealed] = useState(false); - useEffect(() => { - const timer = setTimeout(() => setRevealed(true), REVEAL_DELAY_MS); - return () => clearTimeout(timer); - }, []); - - if (!revealed) { - return ( - - - - ); - } - - return ( - -
- -
- - - - {heading} - - - {subtitle} - - -
- ); + return ; } diff --git a/apps/code/src/renderer/features/sessions/components/ConversationLoadingHint.tsx b/apps/code/src/renderer/features/sessions/components/ConversationLoadingHint.tsx new file mode 100644 index 000000000..0fbc018fc --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/ConversationLoadingHint.tsx @@ -0,0 +1,54 @@ +import { Spinner } from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; +import type { TaskRunStatus } from "@shared/types"; + +interface ConversationLoadingHintProps { + status: "connecting" | "connected" | "disconnected" | "error" | undefined; + isCloud: boolean; + cloudStatus: TaskRunStatus | null | undefined; + eventCount: number; + isPromptPending: boolean | null; +} + +function hintCopy({ + status, + isCloud, + cloudStatus, + eventCount, + isPromptPending, +}: ConversationLoadingHintProps): string | null { + // The footer's GeneratingIndicator already covers the active-turn case. + if (isPromptPending) return null; + if (eventCount === 0) return null; + + if (!isCloud) { + return status === "connecting" ? "Reconnecting to your agent…" : null; + } + + // Cloud: events have started arriving but the run is still in flight and no + // turn is currently being generated — most likely we're catching up on + // historical events from log polling. + if (cloudStatus === "queued" || cloudStatus === "in_progress") { + return "Loading more messages…"; + } + return null; +} + +export function ConversationLoadingHint(props: ConversationLoadingHintProps) { + const copy = hintCopy(props); + if (!copy) return null; + + return ( + + + + {copy} + + + ); +} diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx index 4afb50fd6..4d96a4368 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx @@ -23,6 +23,7 @@ import { type ConversationItem, type TurnContext, } from "./buildConversationItems"; +import { ConversationLoadingHint } from "./ConversationLoadingHint"; import { ConversationSearchBar } from "./ConversationSearchBar"; import { GitActionMessage } from "./GitActionMessage"; import { GitActionResult } from "./GitActionResult"; @@ -281,6 +282,13 @@ export function ConversationView({ itemStyle={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }} footer={
+ { + const timer = setTimeout(() => setRevealed(true), REVEAL_DELAY_MS); + return () => clearTimeout(timer); + }, []); + + if (!revealed) { + return ( + + + + ); + } + + return ( + +
+ +
+ + + + {heading} + + + {subtitle} + + +
+ ); +} diff --git a/apps/code/src/renderer/features/sessions/components/LocalInitializingView.tsx b/apps/code/src/renderer/features/sessions/components/LocalInitializingView.tsx new file mode 100644 index 000000000..9de02f75d --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/LocalInitializingView.tsx @@ -0,0 +1,20 @@ +import { InitializingSplash } from "./InitializingSplash"; + +interface LocalInitializingViewProps { + isResuming: boolean; +} + +export function LocalInitializingView({ + isResuming, +}: LocalInitializingViewProps) { + const { heading, subtitle } = isResuming + ? { + heading: "Loading your conversation…", + subtitle: "Reconnecting to your local agent.", + } + : { + heading: "Getting things ready…", + subtitle: "Spinning up your local agent.", + }; + return ; +} diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index b95675e7d..f7f40ce9d 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -39,6 +39,7 @@ import { import { CloudInitializingView } from "./CloudInitializingView"; import { ConversationView } from "./ConversationView"; import { DropZoneOverlay } from "./DropZoneOverlay"; +import { LocalInitializingView } from "./LocalInitializingView"; import { ModelSelector } from "./ModelSelector"; import { PlanStatusBar } from "./PlanStatusBar"; import { ReasoningLevelSelector } from "./ReasoningLevelSelector"; @@ -66,6 +67,7 @@ interface SessionViewProps { onRetry?: () => void; onNewSession?: () => void; isInitializing?: boolean; + isResumingExistingSession?: boolean; isCloud?: boolean; cloudStatus?: TaskRunStatus | null; slackThreadUrl?: string; @@ -119,6 +121,7 @@ export function SessionView({ onRetry, onNewSession, isInitializing = false, + isResumingExistingSession = false, isCloud = false, cloudStatus = null, slackThreadUrl, @@ -513,13 +516,7 @@ export function SessionView({ isCloud ? ( ) : ( - - - + ) ) : ( <> diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts index 19bbdf26c..1ac5d6937 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts @@ -62,6 +62,7 @@ export function useSessionViewState(taskId: string, task: Task) { isPromptPending, promptStartedAt, isInitializing, + isResumingExistingSession, cloudBranch, errorTitle: session?.errorTitle, errorMessage: diff --git a/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx b/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx index 38ec43c01..5575594f5 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx @@ -56,6 +56,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { isPromptPending, promptStartedAt, isInitializing, + isResumingExistingSession, cloudBranch, cloudStatus, errorTitle, @@ -149,6 +150,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { onRetry={handleRetry} onNewSession={isCloud ? undefined : handleNewSession} isInitializing={isInitializing} + isResumingExistingSession={isResumingExistingSession} isCloud={isCloud} cloudStatus={cloudStatus} slackThreadUrl={slackThreadUrl} From b04f7c842441756b4ca6ce47f47a215cc1af8503 Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Fri, 22 May 2026 17:51:07 -0300 Subject: [PATCH 2/3] use a skeleton placeholder for thread messages instead of splash + pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Loading the thread" and "reconnecting to your agent" are different mechanisms but the same experience from the user's perspective: they're waiting to see their conversation. The previous splash + pill combo mentioned both concepts and felt disjointed. Replace the local initializing splash with a skeleton placeholder of message bubbles that mirrors the real conversation layout, and drop the "Reconnecting to your agent…" pill that was conflating the two states. The cloud queue/sandbox splash stays — that's genuinely different info (no thread to load yet) and the queue status is meaningful. - New MessagesSkeleton: two user bubbles + two assistant text-line groups, max-width matched to CHAT_CONTENT_MAX_WIDTH, animate-pulse. - SessionView local isInitializing branch uses MessagesSkeleton. - Remove ConversationLoadingHint and its usage in ConversationView. - Revert the isResumingExistingSession plumbing — the skeleton doesn't need that distinction. - Delete LocalInitializingView (replaced by skeleton). Generated-By: PostHog Code Task-Id: 743a524d-7374-453b-91b4-e5ab43342707 --- .../components/CommandCenterSessionView.tsx | 2 - .../components/ConversationLoadingHint.tsx | 54 ------------------- .../sessions/components/ConversationView.tsx | 8 --- .../components/LocalInitializingView.tsx | 20 ------- .../sessions/components/MessagesSkeleton.tsx | 36 +++++++++++++ .../sessions/components/SessionView.tsx | 6 +-- .../sessions/hooks/useSessionViewState.ts | 1 - .../task-detail/components/TaskLogsPanel.tsx | 2 - 8 files changed, 38 insertions(+), 91 deletions(-) delete mode 100644 apps/code/src/renderer/features/sessions/components/ConversationLoadingHint.tsx delete mode 100644 apps/code/src/renderer/features/sessions/components/LocalInitializingView.tsx create mode 100644 apps/code/src/renderer/features/sessions/components/MessagesSkeleton.tsx diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx b/apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx index 7a7ac3956..44791e68b 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx +++ b/apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx @@ -30,7 +30,6 @@ export function CommandCenterSessionView({ isPromptPending, promptStartedAt, isInitializing, - isResumingExistingSession, cloudBranch, cloudStatus, errorTitle, @@ -71,7 +70,6 @@ export function CommandCenterSessionView({ onRetry={handleRetry} onNewSession={isCloud ? undefined : handleNewSession} isInitializing={isInitializing} - isResumingExistingSession={isResumingExistingSession} isCloud={isCloud} cloudStatus={cloudStatus} compact diff --git a/apps/code/src/renderer/features/sessions/components/ConversationLoadingHint.tsx b/apps/code/src/renderer/features/sessions/components/ConversationLoadingHint.tsx deleted file mode 100644 index 0fbc018fc..000000000 --- a/apps/code/src/renderer/features/sessions/components/ConversationLoadingHint.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Spinner } from "@phosphor-icons/react"; -import { Flex, Text } from "@radix-ui/themes"; -import type { TaskRunStatus } from "@shared/types"; - -interface ConversationLoadingHintProps { - status: "connecting" | "connected" | "disconnected" | "error" | undefined; - isCloud: boolean; - cloudStatus: TaskRunStatus | null | undefined; - eventCount: number; - isPromptPending: boolean | null; -} - -function hintCopy({ - status, - isCloud, - cloudStatus, - eventCount, - isPromptPending, -}: ConversationLoadingHintProps): string | null { - // The footer's GeneratingIndicator already covers the active-turn case. - if (isPromptPending) return null; - if (eventCount === 0) return null; - - if (!isCloud) { - return status === "connecting" ? "Reconnecting to your agent…" : null; - } - - // Cloud: events have started arriving but the run is still in flight and no - // turn is currently being generated — most likely we're catching up on - // historical events from log polling. - if (cloudStatus === "queued" || cloudStatus === "in_progress") { - return "Loading more messages…"; - } - return null; -} - -export function ConversationLoadingHint(props: ConversationLoadingHintProps) { - const copy = hintCopy(props); - if (!copy) return null; - - return ( - - - - {copy} - - - ); -} diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx index 4d96a4368..4afb50fd6 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx @@ -23,7 +23,6 @@ import { type ConversationItem, type TurnContext, } from "./buildConversationItems"; -import { ConversationLoadingHint } from "./ConversationLoadingHint"; import { ConversationSearchBar } from "./ConversationSearchBar"; import { GitActionMessage } from "./GitActionMessage"; import { GitActionResult } from "./GitActionResult"; @@ -282,13 +281,6 @@ export function ConversationView({ itemStyle={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }} footer={
- ; -} diff --git a/apps/code/src/renderer/features/sessions/components/MessagesSkeleton.tsx b/apps/code/src/renderer/features/sessions/components/MessagesSkeleton.tsx new file mode 100644 index 000000000..adf0a7b7c --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/MessagesSkeleton.tsx @@ -0,0 +1,36 @@ +import { Flex } from "@radix-ui/themes"; +import { CHAT_CONTENT_MAX_WIDTH } from "../constants"; + +function UserBubble({ widthClass }: { widthClass: string }) { + return ( + +
+ + ); +} + +function AssistantLines({ widths }: { widths: string[] }) { + return ( + + {widths.map((w) => ( +
+ ))} + + ); +} + +export function MessagesSkeleton() { + return ( +
+
+ + + + +
+
+ ); +} diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index f7f40ce9d..33981d546 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -39,7 +39,7 @@ import { import { CloudInitializingView } from "./CloudInitializingView"; import { ConversationView } from "./ConversationView"; import { DropZoneOverlay } from "./DropZoneOverlay"; -import { LocalInitializingView } from "./LocalInitializingView"; +import { MessagesSkeleton } from "./MessagesSkeleton"; import { ModelSelector } from "./ModelSelector"; import { PlanStatusBar } from "./PlanStatusBar"; import { ReasoningLevelSelector } from "./ReasoningLevelSelector"; @@ -67,7 +67,6 @@ interface SessionViewProps { onRetry?: () => void; onNewSession?: () => void; isInitializing?: boolean; - isResumingExistingSession?: boolean; isCloud?: boolean; cloudStatus?: TaskRunStatus | null; slackThreadUrl?: string; @@ -121,7 +120,6 @@ export function SessionView({ onRetry, onNewSession, isInitializing = false, - isResumingExistingSession = false, isCloud = false, cloudStatus = null, slackThreadUrl, @@ -516,7 +514,7 @@ export function SessionView({ isCloud ? ( ) : ( - + ) ) : ( <> diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts index 1ac5d6937..19bbdf26c 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts @@ -62,7 +62,6 @@ export function useSessionViewState(taskId: string, task: Task) { isPromptPending, promptStartedAt, isInitializing, - isResumingExistingSession, cloudBranch, errorTitle: session?.errorTitle, errorMessage: diff --git a/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx b/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx index 5575594f5..38ec43c01 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx @@ -56,7 +56,6 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { isPromptPending, promptStartedAt, isInitializing, - isResumingExistingSession, cloudBranch, cloudStatus, errorTitle, @@ -150,7 +149,6 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { onRetry={handleRetry} onNewSession={isCloud ? undefined : handleNewSession} isInitializing={isInitializing} - isResumingExistingSession={isResumingExistingSession} isCloud={isCloud} cloudStatus={cloudStatus} slackThreadUrl={slackThreadUrl} From bee640b12052482d4407457f9a939bca35f3106a Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Fri, 22 May 2026 17:56:55 -0300 Subject: [PATCH 3/3] render skeleton inside conversation slot so input/footer stay mounted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous skeleton lived in SessionView's isInitializing branch as a full-area replacement for the conversation+input layout, so the transition from "loading" to "loaded" swapped the entire screen. Move the skeleton into ConversationView itself, rendered when items.length === 0 inside the same absolute-positioned slot as the VirtualizedList. The surrounding chrome (input area, plan bar, footer) stays mounted the whole time — only the message slot swaps from skeleton bubbles to real messages. The existing "Connecting to agent…" overlay in the input area already handles the agent-spawn transition with its own opacity fade, so no extra indicator needed there. Cloud sessions still route through CloudInitializingView for the queued/in-progress no-events case — the queue/sandbox copy carries real information that the skeleton can't substitute for. Generated-By: PostHog Code Task-Id: 743a524d-7374-453b-91b4-e5ab43342707 --- .../features/sessions/components/ConversationView.tsx | 2 ++ .../features/sessions/components/SessionView.tsx | 9 ++------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx index 4afb50fd6..dd63a93bf 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx @@ -26,6 +26,7 @@ import { import { ConversationSearchBar } from "./ConversationSearchBar"; import { GitActionMessage } from "./GitActionMessage"; import { GitActionResult } from "./GitActionResult"; +import { MessagesSkeleton } from "./MessagesSkeleton"; import { mergeConversationItems } from "./mergeConversationItems"; import { SessionFooter } from "./SessionFooter"; import { QueuedMessageView } from "./session-update/QueuedMessageView"; @@ -268,6 +269,7 @@ export function ConversationView({ /> )} + {items.length === 0 && } - ) : isInitializing ? ( - isCloud ? ( - - ) : ( - - ) + ) : isInitializing && isCloud ? ( + ) : ( <>