Skip to content

Commit dc74915

Browse files
committed
Quiet transient RPC errors and surface chat-send hiccups inline
Transient failures (DO reset, connection loss) were logged as scary console errors with dead-end toasts at every load site. Route them through logRpcFailure — debug-level for transient, loud otherwise — and skip the toasts, since a reconnect or retry is expected to cure them. Failed chat sends now show an inline composer hint instead of a toast; the wording is hedged because a reset after commit means the message may have landed. DO resets on the send path report through reportDoResetError for telemetry.
1 parent ec1d67e commit dc74915

16 files changed

Lines changed: 97 additions & 38 deletions

packages/workshop-frontend/src/BlueprintLandingPage.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { withDoResetRetry } from './rpcErrors'
1+
import { logRpcFailure, withDoResetRetry } from './rpcErrors'
22
import { useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from 'react'
33
import { useNavigate, useParams, useRouter } from '@tanstack/react-router'
44
import { RpcStub, RpcTarget } from 'capnweb'
@@ -121,7 +121,9 @@ export default function BlueprintLandingPage({ rpcStub }: Props) {
121121
// When authenticated, fetch models for binding assignment.
122122
useEffect(() => {
123123
if (isAuthenticated && authenticatedApi) {
124-
authenticatedApi.listModels().then(setModels).catch(console.error)
124+
withDoResetRetry(() => authenticatedApi.listModels())
125+
.then(setModels)
126+
.catch(err => logRpcFailure('Failed to load models:', err))
125127
} else {
126128
setModels([])
127129
}
@@ -191,7 +193,7 @@ export default function BlueprintLandingPage({ rpcStub }: Props) {
191193
}
192194
})
193195
.catch(err => {
194-
console.error('Failed to subscribe to connected accounts:', err)
196+
logRpcFailure('Failed to subscribe to connected accounts:', err)
195197
})
196198

197199
return () => {

packages/workshop-frontend/src/ChatInterface.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isDurableObjectResetError, isTransientRpcError, logRpcFailure, reportDoResetError } from "./rpcErrors";
12
import {
23
Fragment,
34
memo,
@@ -1780,6 +1781,7 @@ export const ChatInput = ({
17801781
attachLabel,
17811782
draftUpdateBanner,
17821783
blockedReason,
1784+
chatKey,
17831785
onStop,
17841786
showThinkingTraces = true,
17851787
onToggleThinkingTraces,
@@ -1825,6 +1827,8 @@ export const ChatInput = ({
18251827
/** When set, the composer is disabled and shows this message — the user must resolve something
18261828
* (e.g. accept/deny a pending connection request) before they can type or send. */
18271829
blockedReason?: string;
1830+
/** Identity of the chat the composer is bound to; a change clears chat-scoped hints. */
1831+
chatKey?: number | null;
18281832
onStop?: () => void;
18291833
showThinkingTraces?: boolean;
18301834
onToggleThinkingTraces?: () => void;
@@ -1838,6 +1842,9 @@ export const ChatInput = ({
18381842
const [capsules, setCapsules] = useState<InputCapsule[]>([]);
18391843
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
18401844
const [isSending, setIsSending] = useState(false);
1845+
const [sendHiccup, setSendHiccup] = useState(false);
1846+
1847+
useEffect(() => setSendHiccup(false), [chatKey]);
18411848
const [isAttachmentDragActive, setIsAttachmentDragActive] = useState(false);
18421849
const [selectedSlashCommand, setSelectedSlashCommand] = useState<SelectedSlashCommand | null>(null);
18431850
// The caret the slash command picker parses at. Deliberately updated only when it moves to a
@@ -2276,6 +2283,7 @@ export const ChatInput = ({
22762283

22772284
const handleSend = async () => {
22782285
if (sendInFlightRef.current || isSending || isBlocked) return;
2286+
setSendHiccup(false);
22792287
const attachmentsSnapshot = pendingAttachments;
22802288
const readyAttachments = attachmentsSnapshot
22812289
.filter((attachment) => attachment.uploadState === "ready" && attachment.ref)
@@ -2455,7 +2463,8 @@ export const ChatInput = ({
24552463

24562464
const submitMessage = () => {
24572465
void handleSend().catch((err) => {
2458-
console.error("Failed to send chat message:", err);
2466+
// The onSend handlers already log; the composer only needs the hint state.
2467+
if (isTransientRpcError(err)) setSendHiccup(true);
24592468
});
24602469
};
24612470

@@ -3050,6 +3059,11 @@ export const ChatInput = ({
30503059
</div>
30513060
)}
30523061
{draftUpdateBanner}
3062+
{sendHiccup && (
3063+
<div className="px-4 pt-2 text-xs text-kumo-warning">
3064+
Connection hiccup — your message may not have been sent. Check the thread, then try again.
3065+
</div>
3066+
)}
30533067
{/* Textarea */}
30543068
<div className="relative px-4 pb-1 pt-3">
30553069
{slashCommandPicker.popup}
@@ -5213,9 +5227,10 @@ function ChatInterface({
52135227
forceUpdate();
52145228
}
52155229
} catch (err) {
5216-
console.error("Failed to subscribe to chats:", err);
5217-
reportIssue('chat.subscription-load', err)
5218-
toasts.add({ title: "Unable to load conversations", variant: "error" });
5230+
if (!logRpcFailure("Failed to subscribe to chats:", err)) {
5231+
reportIssue('chat.subscription-load', err)
5232+
toasts.add({ title: "Unable to load conversations", variant: "error" });
5233+
}
52195234
}
52205235
};
52215236

@@ -5366,8 +5381,10 @@ function ChatInterface({
53665381
);
53675382
}
53685383
} catch (err) {
5369-
console.error("Failed to send message:", err);
5370-
toasts.add({ title: "Failed to send message", variant: "error" });
5384+
if (isDurableObjectResetError(err)) reportDoResetError("chat.send", err);
5385+
if (!logRpcFailure("Failed to send message:", err)) {
5386+
toasts.add({ title: "Failed to send message", variant: "error" });
5387+
}
53715388
throw err;
53725389
}
53735390
};
@@ -5388,8 +5405,10 @@ function ChatInterface({
53885405
message, model, capsules, attachments, formats);
53895406
onNavigateToChatRef.current(newChatId);
53905407
} catch (err) {
5391-
console.error("Failed to create new chat:", err);
5392-
toasts.add({ title: "Failed to start conversation", variant: "error" });
5408+
if (isDurableObjectResetError(err)) reportDoResetError("chat.new", err);
5409+
if (!logRpcFailure("Failed to create new chat:", err)) {
5410+
toasts.add({ title: "Failed to start conversation", variant: "error" });
5411+
}
53935412
throw err;
53945413
}
53955414
};
@@ -7576,6 +7595,7 @@ function ChatInterface({
75767595
<div className={`flex-shrink-0 bg-kumo-base ${sidebarMode ? "" : "border-t border-kumo-line"}`}>
75777596
<div className={useConstrainedChatWidth ? "mx-auto w-full max-w-[920px]" : ""}>
75787597
<ChatInput
7598+
chatKey={selectedChatId}
75797599
createCapsuleGatekeeper={(accountId, url) =>
75807600
overseer.newGatekeeper(accountId, url)
75817601
}

packages/workshop-frontend/src/ConnectAccountModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { RpcStub } from 'capnweb'
44
import { AuthenticatedApi, GatekeeperVendorFilter } from '@gadgets/workshop-shared/api'
55
import { VendorDescription } from '@gadgets/workshop-shared/gatekeeper'
66
import VendorCard from './VendorCard'
7+
import { withDoResetRetry } from './rpcErrors'
78

89
interface ConnectAccountModalProps {
910
visible: boolean
@@ -41,7 +42,7 @@ export default function ConnectAccountModal({
4142
const fetchVendors = async () => {
4243
setVendorsLoading(true)
4344
try {
44-
const vendorList = await authenticatedApi.listGatekeeperVendors(filter)
45+
const vendorList = await withDoResetRetry(() => authenticatedApi.listGatekeeperVendors(filter))
4546
const unavailable = vendorList.filter(v => v.unavailable)
4647
if (unavailable.length > 0) {
4748
toasts.add({

packages/workshop-frontend/src/Connections.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export default function Connections({ overseer, gadget, chatId, authenticatedApi
7070
setHooks(hookList.filter((hook) => hook.gadgetId === id))
7171
onHasGatekeepersChange?.(bindingList.length > 0)
7272
} catch (err) {
73+
// Loud on purpose: this panel has no retry path, so a quieted transient failure would
74+
// silently render "no connected resources".
7375
console.error('Failed to load gatekeepers:', err)
7476
reportIssue('connections.load', err)
7577
toasts.add({ title: 'Failed to load connections', variant: 'error' })

packages/workshop-frontend/src/GatekeeperModal.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { withDoResetRetry } from './rpcErrors'
1+
import { logRpcFailure, withDoResetRetry } from './rpcErrors'
22
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
33
import { Dialog, useKumoToastManager, type PortalContainer } from '@cloudflare/kumo'
44
import {
@@ -369,7 +369,7 @@ export default function GatekeeperModal({
369369
setSpawnerEnv(
370370
(spawnerEnvCandidatesRef.current ?? []).map(entry => ({ ...entry, enabled: true })))
371371

372-
authenticatedApi.listModels().then(models => {
372+
withDoResetRetry(() => authenticatedApi.listModels()).then(models => {
373373
if (cancelled) return
374374
setAvailableModels(models)
375375
if (models.length > 0) {
@@ -388,7 +388,7 @@ export default function GatekeeperModal({
388388
toasts.add({ title: "Couldn't load AI models", variant: 'error' })
389389
})
390390

391-
authenticatedApi.listGatekeeperVendors().then(vendors => {
391+
withDoResetRetry(() => authenticatedApi.listGatekeeperVendors()).then(vendors => {
392392
if (cancelled) return
393393
setVendors(vendors)
394394
}).catch(err => {
@@ -441,7 +441,7 @@ export default function GatekeeperModal({
441441
}
442442
})
443443
.catch(error => {
444-
console.error('Failed to subscribe to connected accounts:', error)
444+
logRpcFailure('Failed to subscribe to connected accounts:', error)
445445
})
446446

447447
return () => {

packages/workshop-frontend/src/ObserverConfigModal.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ export default function ObserverConfigModal({
116116
subStub = stub
117117
})
118118
.catch(err => {
119+
// Loud on purpose: the modal has no retry path, so a quieted transient failure would
120+
// strand the user on a permanent loader.
119121
console.error('Failed to subscribe to connected accounts:', err)
120122
toasts.add({ title: 'Failed to load your connected accounts', variant: 'error' })
121123
})

packages/workshop-frontend/src/OnboardingWizard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { withDoResetRetry } from './rpcErrors'
1+
import { logRpcFailure, withDoResetRetry } from './rpcErrors'
22
import { useState, useEffect, useRef, useCallback } from 'react'
33
import { useKumoToastManager } from '@cloudflare/kumo'
44
import { RpcTarget } from 'capnweb'
@@ -230,7 +230,7 @@ export default function OnboardingWizard({
230230
}
231231
})
232232
.catch((err) => {
233-
console.error('Failed to subscribe to connected accounts:', err)
233+
logRpcFailure('Failed to subscribe to connected accounts:', err)
234234
})
235235

236236
return () => {

packages/workshop-frontend/src/ResourcePicker.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { withDoResetRetry } from './rpcErrors'
1+
import { logRpcFailure, withDoResetRetry } from './rpcErrors'
22
import { useState, useEffect, useRef, useMemo, useCallback, type MutableRefObject } from 'react'
33
import { Tooltip, useKumoToastManager } from '@cloudflare/kumo'
44
import { Plus, CaretRight, Warning } from '@phosphor-icons/react'
@@ -162,7 +162,7 @@ export default function ResourcePicker({
162162
() => authenticatedApi.subscribeConnectedAccounts(subscriber))
163163
subscriptionRef.current = { stub }
164164
} catch (error) {
165-
console.error('Failed to subscribe to connected accounts:', error)
165+
logRpcFailure('Failed to subscribe to connected accounts:', error)
166166
// Nothing more is coming, so show what we have rather than hiding forever.
167167
setAccountsLoaded(true)
168168
}

packages/workshop-frontend/src/components/AppShell/SidebarWorkspaces.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { withDoResetRetry } from '../../rpcErrors'
1+
import { logRpcFailure, withDoResetRetry } from '../../rpcErrors'
22
import {
33
createContext,
44
useCallback,
@@ -97,7 +97,7 @@ export function SidebarWorkspacesProvider({ children }: { children: ReactNode })
9797
setGadgetsLoading(false)
9898
})
9999
.catch((err) => {
100-
console.error('Failed to load workspaces for sidebar:', err)
100+
logRpcFailure('Failed to load workspaces for sidebar:', err)
101101
if (!cancelled) setGadgetsLoading(false)
102102
})
103103
return () => { cancelled = true }

packages/workshop-frontend/src/routes/__root.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { withDoResetRetry } from '../rpcErrors'
1+
import { logRpcFailure, withDoResetRetry } from '../rpcErrors'
22
import { useState, useEffect } from 'react'
33
import { createRootRoute, Outlet, useRouterState } from '@tanstack/react-router'
44
import { TooltipProvider, Toasty } from '@cloudflare/kumo'
@@ -155,7 +155,7 @@ function AuthenticatedShell({
155155
withDoResetRetry(() => authenticatedApi.isOnboardingCompleted()).then((completed) => {
156156
if (!cancelled) setOnboardingNeeded(!completed)
157157
}).catch((err) => {
158-
console.error('Failed to check onboarding status:', err)
158+
logRpcFailure('Failed to check onboarding status:', err)
159159
// If the check fails, skip onboarding to avoid blocking the user
160160
if (!cancelled) setOnboardingNeeded(false)
161161
})

0 commit comments

Comments
 (0)