Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ export default function GatewayApp() {
const { api, terminalClient, sftpClient, gitClient } = useGatewayClients(token);
const [status, setStatus] = useState<AgentStatus | null>(null);
const [statusError, setStatusError] = useState<string | null>(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<string | null>(null);
// Sidebar errors raised outside the sidebar store (project removal flow).
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -3579,6 +3601,7 @@ export default function GatewayApp() {
canShareConversations={canShareHistory}
sharedConversationCount={sharedHistoryItems.length}
externalErrorMessage={sidebarActionError}
connectionLost={gatewayConnectionLost}
isLocalDraftConversationId={isLocalDraftConversationId}
onProjectsCollapsedChange={handleSidebarProjectsCollapsedChange}
onRecentCollapsedChange={handleSidebarRecentCollapsedChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Args extends unknown[], Return>(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -193,18 +210,22 @@ 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;
}
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,
Expand Down Expand Up @@ -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}
Expand Down
68 changes: 28 additions & 40 deletions crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "../../lib/settings";
import { cn } from "../../lib/shared/utils";
import {
AlertCircle,
ChevronRight,
Edit3,
Folder,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1017,32 +1021,6 @@ function HistoryListLoadingSkeleton() {
);
}

function SidebarStateCard(props: {
title: string;
description?: string;
tone?: "default" | "error";
}) {
const { title, description, tone = "default" } = props;

return (
<div
className={cn(
"rounded-2xl border px-3 py-3 text-sm",
tone === "error"
? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/60 bg-background/70 text-muted-foreground",
)}
>
<div
className={cn("font-medium", tone === "error" ? "text-destructive" : "text-foreground/85")}
>
{title}
</div>
{description ? <div className="mt-1 text-xs leading-5">{description}</div> : null}
</div>
);
}

export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHistorySidebarProps) {
const {
items,
Expand All @@ -1055,6 +1033,7 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
hasMore,
isLoadingMore,
errorMessage,
sectionsDisabled = false,
renamingId,
renameDraft,
isOpen,
Expand Down Expand Up @@ -1655,9 +1634,12 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
<div
ref={sidebarSectionsRef}
style={{ gridTemplateRows: sidebarSectionLayout.gridTemplateRows }}
aria-disabled={sectionsDisabled || undefined}
inert={sectionsDisabled}
className={cn(
"grid min-h-0 flex-1 content-start",
isProjectSectionResizing ? undefined : SIDEBAR_SECTION_ROWS_TRANSITION_CLASS,
sectionsDisabled && "pointer-events-none select-none opacity-50",
)}
>
{showProjects ? (
Expand Down Expand Up @@ -1828,7 +1810,22 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
{t("chat.history.syncing")}
</span>
) : null}
<div className="rounded-full border border-border/60 bg-background/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<div
role={errorMessage ? "status" : undefined}
title={errorMessage ? `${t("chat.historyReadFailed")}: ${errorMessage}` : undefined}
className={cn(
"flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
errorMessage
? "border-destructive/40 bg-destructive/10 text-destructive"
: "border-border/60 bg-background/80 text-muted-foreground",
)}
>
{errorMessage ? (
<AlertCircle
className="h-3 w-3 shrink-0"
aria-label={t("chat.historyReadFailed")}
/>
) : null}
{Math.max(totalItems, items.length)}
</div>
{canShareConversations ? (
Expand Down Expand Up @@ -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 ? (
<div className="shrink-0 px-3 pb-2">
<SidebarStateCard
title={t("chat.historyReadFailed")}
description={errorMessage}
tone="error"
/>
</div>
) : 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. */}
<div
ref={historyScrollRef}
aria-busy={listStatus === "loading" || listStatus === "syncing" || isLoadingMore}
Expand Down
37 changes: 34 additions & 3 deletions crates/agent-gateway/web/src/lib/sidebar/webSidebarBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import type { ActivityStore } from "@/lib/chat/stream/activityStore";
import { formatConversationTitle } from "@/lib/chatUi";
import type {
AgentStatus,
ConversationSummary,
GatewayHistoryEvent,
HistoryList,
Expand Down Expand Up @@ -115,6 +116,7 @@ type WebSidebarApi = {
deleteHistory(conversationId: string): Promise<void>;
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 = {
Expand Down Expand Up @@ -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(),
Expand Down
Loading