diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 085f1633..1984dab8 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -207,6 +207,9 @@ export default function GatewayApp() { const { api, terminalClient, sftpClient, gitClient } = useGatewayClients(token); const [status, setStatus] = useState(null); const [statusError, setStatusError] = useState(null); + // True only after an authenticated gateway connection has been established + // and then dropped; the initial connect (skeleton phase) never disables UI. + const [gatewayConnectionLost, setGatewayConnectionLost] = useState(false); const [conversationId, setConversationId] = useState(""); const [chatError, setChatError] = useState(null); // Sidebar errors raised outside the sidebar store (project removal flow). @@ -1159,6 +1162,25 @@ export default function GatewayApp() { }; }, [api]); + useEffect(() => { + if (!api) { + setGatewayConnectionLost(false); + return; + } + let wasConnected = false; + const unsubscribe = api.subscribeConnection((connected) => { + if (connected) { + wasConnected = true; + setGatewayConnectionLost(false); + } else if (wasConnected) { + setGatewayConnectionLost(true); + } + }); + return () => { + unsubscribe(); + }; + }, [api]); + const refreshChatQueueSnapshot = useCallback( (targetConversationId: string, currentApi = api) => { const conversationIdValue = targetConversationId.trim(); @@ -3579,6 +3601,7 @@ export default function GatewayApp() { canShareConversations={canShareHistory} sharedConversationCount={sharedHistoryItems.length} externalErrorMessage={sidebarActionError} + connectionLost={gatewayConnectionLost} isLocalDraftConversationId={isLocalDraftConversationId} onProjectsCollapsedChange={handleSidebarProjectsCollapsedChange} onRecentCollapsedChange={handleSidebarRecentCollapsedChange} diff --git a/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx b/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx index d28e7d32..64021d0b 100644 --- a/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx +++ b/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx @@ -32,6 +32,19 @@ function selectConversationIndex(snapshot: SidebarSnapshot) { return snapshot.byId; } +// Transport-shaped list errors merely restate "the gateway socket is down"; +// the page-level banner owns that story, so the sidebar never repeats it — +// including the stale copy that lingers until the reconnect refetch lands. +// Mirrors isRecoverableGatewayTransportError in lib/gatewaySocket.ts. +function isGatewayTransportErrorDetail(detail: string | null | undefined) { + const message = (detail ?? "").trim(); + return ( + message.startsWith("Gateway WebSocket disconnected") || + message === "Gateway WebSocket is not connected" || + message.startsWith("Gateway transport stalled") + ); +} + // Stable identity wrapper so callback props from GatewayApp (recreated per // render) never churn effects or the memo'd view rows. function useStableCallback( @@ -63,6 +76,9 @@ export type GatewaySidebarContainerProps = { // GatewayApp-level sidebar errors (project removal flow); store errors are // derived locally and take precedence. externalErrorMessage: string | null; + // Gateway socket dropped after having been connected: the sections are + // disabled and error cards are suppressed (the page banner owns messaging). + connectionLost: boolean; isLocalDraftConversationId: (id: string) => boolean; onProjectsCollapsedChange: (collapsed: boolean) => void; onRecentCollapsedChange: (collapsed: boolean) => void; @@ -92,7 +108,8 @@ export type GatewaySidebarContainerProps = { }; export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { - const { store, projects, externalErrorMessage, isLocalDraftConversationId } = props; + const { store, projects, externalErrorMessage, connectionLost, isLocalDraftConversationId } = + props; const { t } = useLocale(); const items = useSidebarSelector(store, selectConversations); @@ -193,6 +210,9 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { [t], ); const errorMessage = useMemo(() => { + if (connectionLost) { + return null; + } let lastMutationError: SidebarErrorCode | null = null; for (const code of mutationErrors.values()) { lastMutationError = code; @@ -200,11 +220,12 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { if (lastMutationError) { return translateErrorCode(lastMutationError); } - if (listState.error) { + if (listState.error && !isGatewayTransportErrorDetail(listState.errorDetail)) { return listState.errorDetail?.trim() || translateErrorCode(listState.error); } return externalErrorMessage; }, [ + connectionLost, externalErrorMessage, listState.error, listState.errorDetail, @@ -234,6 +255,7 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { hasMore={listState.hasMore} isLoadingMore={listState.isLoadingMore} errorMessage={errorMessage} + sectionsDisabled={connectionLost} renamingId={renamingId} renameDraft={renameDraft} isOpen={props.isOpen} diff --git a/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx index fe1a7a32..8d6b4885 100644 --- a/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx @@ -19,6 +19,7 @@ import { } from "../../lib/settings"; import { cn } from "../../lib/shared/utils"; import { + AlertCircle, ChevronRight, Edit3, Folder, @@ -62,6 +63,9 @@ type ChatHistorySidebarProps = { hasMore: boolean; isLoadingMore: boolean; errorMessage: string | null; + // Disables the workspace + recent-conversation sections as one block (used + // while the gateway connection is lost); the header actions stay usable. + sectionsDisabled?: boolean; renamingId: string | null; renameDraft: string; isOpen: boolean; @@ -1017,32 +1021,6 @@ function HistoryListLoadingSkeleton() { ); } -function SidebarStateCard(props: { - title: string; - description?: string; - tone?: "default" | "error"; -}) { - const { title, description, tone = "default" } = props; - - return ( -
-
- {title} -
- {description ?
{description}
: null} -
- ); -} - export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHistorySidebarProps) { const { items, @@ -1055,6 +1033,7 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi hasMore, isLoadingMore, errorMessage, + sectionsDisabled = false, renamingId, renameDraft, isOpen, @@ -1655,9 +1634,12 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
{showProjects ? ( @@ -1828,7 +1810,22 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi {t("chat.history.syncing")} ) : null} -
+
+ {errorMessage ? ( + + ) : null} {Math.max(totalItems, items.length)}
{canShareConversations ? ( @@ -1863,19 +1860,10 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi : "translate-y-0 opacity-100", )} > - {/* Render priority: error banner ABOVE rows (never replacing - them); skeleton only while loading with nothing cached; rows - whenever present; empty state only when authoritatively - empty. */} - {errorMessage ? ( -
- -
- ) : null} + {/* Render priority: read failures surface as the red count badge + in the section header (never a card replacing rows); skeleton + only while loading with nothing cached; rows whenever present; + empty state only when authoritatively empty. */}
; subscribeHistory(listener: (event: GatewayHistoryEvent) => void): () => void; subscribeConnection(listener: (connected: boolean) => void): () => void; + subscribeStatus(listener: (status: AgentStatus | null, error: string | null) => void): () => void; }; export type WebSidebarBackendDeps = { @@ -220,9 +222,38 @@ export function createWebSidebarBackend(deps: WebSidebarBackendDeps): SidebarBac }, subscribeConnection(listener) { - // The client emits booleans already (true on auth, false on drop) and - // replays the current state to late subscribers. - return api.subscribeConnection((connected) => listener(connected === true)); + // For the sidebar, "connected" means the whole read path works: the + // browser⇄gateway socket is authenticated AND the desktop agent behind + // it is online. The agent can drop and return while the socket never + // blips (and after a gateway restart the socket recovers before the + // agent has re-registered), so folding agent online-ness in here makes + // the store's reconnect refetch fire the moment reads can actually + // succeed — clearing any stale listError immediately instead of on the + // next reconcile tick. Both sources replay their current state to late + // subscribers; dedup keeps the store's disconnect latch edge-triggered. + let socketConnected = false; + let agentOnline = false; + let lastEmitted: boolean | null = null; + const emit = () => { + const next = socketConnected && agentOnline; + if (next === lastEmitted) { + return; + } + lastEmitted = next; + listener(next); + }; + const unsubscribeConnection = api.subscribeConnection((connected) => { + socketConnected = connected === true; + emit(); + }); + const unsubscribeStatus = api.subscribeStatus((status) => { + agentOnline = status?.online === true; + emit(); + }); + return () => { + unsubscribeConnection(); + unsubscribeStatus(); + }; }, getProtectedConversationIds: () => deps.getProtectedConversationIds(),