Skip to content

Commit b41d16a

Browse files
committed
feat(plugins): show connection/auth/rpc errors instead of spinning
Every built-in plugin now renders a clear connection state — connecting, disconnected, unauthorized, or error, with a reload affordance — driven by the devframe client's status, replacing the indefinite spinners that appeared when the socket dropped or auth was refused. git (React), inspect (Vue), terminals (Svelte), code-server (vanilla) and messages (Vue) gate their surface on the status; a11y (Solid) keeps its BroadcastChannel-first UI and surfaces a degraded backend as a quiet tag.
1 parent d5448c7 commit b41d16a

16 files changed

Lines changed: 386 additions & 27 deletions

File tree

plugins/a11y/src/spa/app.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function App() {
5252
scanning={channel.scanning()}
5353
onRescan={channel.rescan}
5454
/>
55-
<MetaLine report={channel.report} backend={devframe.backend} />
55+
<MetaLine report={channel.report} backend={devframe.backend} status={devframe.status} />
5656

5757
<Show when={channel.report() && total() > 0}>
5858
<Summary counts={counts()} active={filter()} onToggle={toggleFilter} />

plugins/a11y/src/spa/components/header.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,24 @@ export function Header(props: HeaderProps) {
4545
)
4646
}
4747

48-
export function MetaLine(props: { report: Accessor<ScanReport | null>, backend: Accessor<string | null> }) {
48+
export function MetaLine(props: {
49+
report: Accessor<ScanReport | null>
50+
backend: Accessor<string | null>
51+
status?: Accessor<string | null>
52+
}) {
53+
// The backend is optional here, so a degraded connection is shown as a quiet
54+
// tag rather than taking over the panel.
55+
const degraded = () => {
56+
const s = props.status?.()
57+
return s === 'disconnected' || s === 'unauthorized' || s === 'error' ? s : null
58+
}
4959
return (
5060
<Show when={props.report()}>
5161
{report => (
5262
<div class="meta">
5363
<span class="meta__url" title={report().url}>{report().url}</span>
54-
<Show when={props.backend()}>
55-
{b => <span class="meta__tag">{b()}</span>}
64+
<Show when={degraded()} fallback={<Show when={props.backend()}>{b => <span class="meta__tag">{b()}</span>}</Show>}>
65+
{s => <span class="meta__tag meta__tag--warn" title="devframe backend connection">{s()}</span>}
5666
</Show>
5767
<span class="meta__tag">
5868
axe

plugins/a11y/src/spa/lib/devframe.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { DevframeConnectionStatus } from 'devframe/client'
12
import type { Accessor } from 'solid-js'
23
import type { Impact } from '../../shared/protocol.ts'
34
import { connectDevframe } from 'devframe/client'
@@ -19,6 +20,12 @@ export interface A11yConfig {
1920
export interface DevframeState {
2021
/** `'websocket'` in dev, `'static'` for a baked build, `null` while/if unreachable. */
2122
backend: Accessor<string | null>
23+
/**
24+
* Connection status of the (optional) devframe backend. The panel's core
25+
* scan loop runs over BroadcastChannel, so this is informational only —
26+
* surfaced as a tag rather than blocking the UI.
27+
*/
28+
status: Accessor<DevframeConnectionStatus | null>
2229
/** Impact taxonomy + copy from the `get-config` RPC. */
2330
config: Accessor<A11yConfig | null>
2431
}
@@ -31,11 +38,14 @@ export interface DevframeState {
3138
*/
3239
export function connectDevframeState(): DevframeState {
3340
const [backend, setBackend] = createSignal<string | null>(null)
41+
const [status, setStatus] = createSignal<DevframeConnectionStatus | null>(null)
3442
const [config, setConfig] = createSignal<A11yConfig | null>(null)
3543

3644
connectDevframe()
3745
.then(async (rpc) => {
3846
setBackend(rpc.connectionMeta.backend)
47+
setStatus(rpc.status)
48+
rpc.events.on('connection:status', s => setStatus(s))
3949
try {
4050
await rpc.ensureTrusted(2000)
4151
}
@@ -51,7 +61,8 @@ export function connectDevframeState(): DevframeState {
5161
})
5262
.catch(() => {
5363
// No reachable backend (e.g. agent loaded outside a devframe host).
64+
setStatus('error')
5465
})
5566

56-
return { backend, config }
67+
return { backend, status, config }
5768
}

plugins/a11y/src/spa/styles.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ body {
148148
border-radius: 5px;
149149
}
150150

151+
.meta__tag--warn {
152+
border-color: color-mix(in oklab, #d9a441 60%, var(--line));
153+
color: #d9a441;
154+
text-transform: capitalize;
155+
}
156+
151157
/* ── severity summary ──────────────────────────────────────────────────── */
152158

153159
.summary {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { DevframeConnectionStatus } from 'devframe/client'
2+
import { button, cx } from './design'
3+
4+
interface StateCopy {
5+
icon: string
6+
title: string
7+
body: string
8+
}
9+
10+
const COPY: Record<Exclude<DevframeConnectionStatus, 'connected'>, StateCopy> = {
11+
connecting: {
12+
icon: 'i-ph-plugs-connected-duotone',
13+
title: 'Connecting…',
14+
body: 'Establishing a connection to the devframe server.',
15+
},
16+
disconnected: {
17+
icon: 'i-ph-plugs-duotone',
18+
title: 'Disconnected',
19+
body: 'Lost the connection to the devframe server. Reload once it is back up.',
20+
},
21+
unauthorized: {
22+
icon: 'i-ph-lock-key-duotone',
23+
title: 'Not authorized',
24+
body: 'Reopen the link printed by your dev server, then reload.',
25+
},
26+
error: {
27+
icon: 'i-ph-warning-octagon-duotone',
28+
title: 'Connection failed',
29+
body: 'Could not reach the devframe server.',
30+
},
31+
}
32+
33+
export interface ConnectionStateHandle {
34+
/** Render for a status. Returns `true` when it took over the container. */
35+
update: (status: DevframeConnectionStatus, error?: string | null) => boolean
36+
dispose: () => void
37+
}
38+
39+
/**
40+
* A full-container connection state, shown whenever the devframe client isn't
41+
* `connected` so the launcher never sits on an unexplained blank while the
42+
* socket is down. Reload is the recovery path (no auto-reconnect).
43+
*/
44+
export function createConnectionState(container: HTMLElement): ConnectionStateHandle {
45+
const root = document.createElement('div')
46+
root.className = 'absolute inset-0 flex flex-col items-center justify-center gap-4 bg-base color-base p-8 text-center z-nav'
47+
root.hidden = true
48+
container.append(root)
49+
50+
function update(status: DevframeConnectionStatus, error?: string | null): boolean {
51+
if (status === 'connected') {
52+
root.hidden = true
53+
root.replaceChildren()
54+
return false
55+
}
56+
const copy = COPY[status]
57+
root.replaceChildren()
58+
59+
const glyph = document.createElement('div')
60+
glyph.className = cx(copy.icon, 'text-4xl color-active')
61+
root.append(glyph)
62+
63+
const text = document.createElement('div')
64+
text.className = 'flex flex-col gap-1'
65+
const title = document.createElement('p')
66+
title.className = 'text-lg font-medium'
67+
title.textContent = copy.title
68+
const body = document.createElement('p')
69+
body.className = 'text-sm color-muted max-w-sm'
70+
body.textContent = copy.body
71+
text.append(title, body)
72+
if (error && status === 'error') {
73+
const code = document.createElement('code')
74+
code.className = 'mt-1 max-w-sm break-words font-mono text-xs color-faint'
75+
code.textContent = error
76+
text.append(code)
77+
}
78+
root.append(text)
79+
80+
if (status !== 'connecting') {
81+
const reload = document.createElement('button')
82+
reload.type = 'button'
83+
reload.className = button({ variant: 'primary', size: 'sm' })
84+
reload.textContent = 'Reload'
85+
reload.addEventListener('click', () => location.reload())
86+
root.append(reload)
87+
}
88+
89+
root.hidden = false
90+
return true
91+
}
92+
93+
return {
94+
update,
95+
dispose() {
96+
root.remove()
97+
},
98+
}
99+
}

plugins/code-server/src/client/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
import type { CodeServerViewState } from './view'
1010
import { connectDevframe } from 'devframe/client'
1111
import { STATE_KEY } from '../constants'
12+
import { createConnectionState } from './connection-state'
1213
import { createCodeServerView } from './view'
1314

1415
export interface MountCodeServerOptions {
@@ -42,6 +43,18 @@ export async function mountCodeServer(
4243
if (rpc.connectionMeta.backend === 'websocket')
4344
await rpc.ensureTrusted(5000).catch(() => {})
4445

46+
// Overlay the launcher with a clear connection state whenever the client
47+
// isn't connected — a dropped socket or refused auth would otherwise leave
48+
// an unexplained blank / stale editor. Needs a positioned container.
49+
if (!container.style.position)
50+
container.style.position = 'relative'
51+
const connectionState = createConnectionState(container)
52+
const syncConnection = (): void => {
53+
connectionState.update(rpc.status, rpc.connectionError?.message)
54+
}
55+
syncConnection()
56+
const offConnection = rpc.events.on('connection:status', syncConnection)
57+
4558
let detection: CodeServerDetection = { checked: false, installed: false, bin: 'code-server' }
4659
let server: CodeServerServerInfo = { status: 'stopped' }
4760
let auth: CodeServerAuth | undefined
@@ -148,6 +161,8 @@ export async function mountCodeServer(
148161
dispose() {
149162
disposed = true
150163
off?.()
164+
offConnection?.()
165+
connectionState.dispose()
151166
view.dispose()
152167
},
153168
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use client'
2+
3+
import type { DevframeConnectionStatus } from 'devframe/client'
4+
import { button } from '../lib/design'
5+
import { Icon } from './ui/icon'
6+
7+
interface StateCopy {
8+
icon: string
9+
title: string
10+
body: string
11+
spin?: boolean
12+
}
13+
14+
const COPY: Record<Exclude<DevframeConnectionStatus, 'connected'>, StateCopy> = {
15+
connecting: {
16+
icon: 'i-ph-plugs-connected-duotone',
17+
title: 'Connecting…',
18+
body: 'Establishing a connection to the devframe server.',
19+
spin: true,
20+
},
21+
disconnected: {
22+
icon: 'i-ph-plugs-duotone',
23+
title: 'Disconnected',
24+
body: 'Lost the connection to the devframe server. Reload once it is back up.',
25+
},
26+
unauthorized: {
27+
icon: 'i-ph-lock-key-duotone',
28+
title: 'Not authorized',
29+
body: 'This client isn’t authorized. Reopen the link printed by your dev server, then reload.',
30+
},
31+
error: {
32+
icon: 'i-ph-warning-octagon-duotone',
33+
title: 'Connection failed',
34+
body: 'Could not reach the devframe server.',
35+
},
36+
}
37+
38+
/**
39+
* Full-panel connection state — shown whenever the client isn't `connected`,
40+
* so the UI never sits on an infinite spinner without saying why. Reload is the
41+
* recovery path (the client doesn't auto-reconnect).
42+
*/
43+
export function ConnectionState({ status, error }: { status: DevframeConnectionStatus, error?: string | null }) {
44+
if (status === 'connected')
45+
return null
46+
const copy = COPY[status]
47+
return (
48+
<div className="bg-base flex h-svh w-full flex-col items-center justify-center gap-4 p-8 text-center">
49+
<Icon
50+
name={copy.icon}
51+
className={`color-active size-10 ${copy.spin ? 'animate-pulse' : ''}`}
52+
/>
53+
<div className="flex flex-col gap-1">
54+
<p className="color-base text-lg font-medium">{copy.title}</p>
55+
<p className="color-muted max-w-sm text-sm">{copy.body}</p>
56+
{error && status === 'error' && (
57+
<p className="color-faint mt-1 max-w-sm break-words font-mono text-xs">{error}</p>
58+
)}
59+
</div>
60+
{status !== 'connecting' && (
61+
<button type="button" className={button({ variant: 'primary', size: 'sm' })} onClick={() => location.reload()}>
62+
<Icon name="i-ph-arrow-clockwise" className="size-4" />
63+
Reload
64+
</button>
65+
)}
66+
</div>
67+
)
68+
}

plugins/git/src/client/components/dashboard.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { GitBranches } from '../../index'
66
import { useCallback, useEffect, useRef, useState } from 'react'
77
import { nav as navBar, navBrand, tab as tabClass, tabsList } from '../lib/design'
88
import { CommitDetailsPanel } from './commit-details-panel'
9+
import { ConnectionState } from './connection-state'
910
import { LogPanel } from './log-panel'
1011
import { RpcProvider, useRpc } from './rpc-provider'
1112
import { StatusPanel } from './status-panel'
@@ -91,10 +92,12 @@ function Resizer({ onPointerDown, label }: { onPointerDown: (e: ReactPointerEven
9192
}
9293

9394
function ConnectionBadge() {
94-
const { rpc, error } = useRpc()
95-
if (error)
96-
return <Badge variant="destructive">disconnected</Badge>
97-
if (!rpc)
95+
const { rpc, status } = useRpc()
96+
if (status === 'error' || status === 'disconnected')
97+
return <Badge variant="destructive">{status}</Badge>
98+
if (status === 'unauthorized')
99+
return <Badge variant="warning">unauthorized</Badge>
100+
if (status === 'connecting' || !rpc)
98101
return <Badge variant="secondary">connecting…</Badge>
99102
const backend = rpc.connectionMeta.backend
100103
return (
@@ -251,10 +254,20 @@ function DashboardBody() {
251254
)
252255
}
253256

257+
function DashboardGate() {
258+
const { status, error } = useRpc()
259+
// A static backend reports `connected` immediately; a websocket backend
260+
// shows the connection state until it's live, so data views never spin
261+
// against a socket that will never answer.
262+
if (status !== 'connected')
263+
return <ConnectionState status={status} error={error} />
264+
return <DashboardBody />
265+
}
266+
254267
export function Dashboard() {
255268
return (
256269
<RpcProvider>
257-
<DashboardBody />
270+
<DashboardGate />
258271
</RpcProvider>
259272
)
260273
}

0 commit comments

Comments
 (0)