@chanlerdev/scorel 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -0
- package/dist/index.js +6675 -0
- package/dist/index.js.map +7 -0
- package/docs/CHANGELOG.md +12 -0
- package/docs/README.md +116 -0
- package/docs/ROADMAP.md +669 -0
- package/docs/SHIP.md +242 -0
- package/docs/spec/channels.md +156 -0
- package/docs/spec/client.md +326 -0
- package/docs/spec/daemon.md +408 -0
- package/docs/spec/events.md +423 -0
- package/docs/spec/extensions.md +255 -0
- package/docs/spec/relay.md +391 -0
- package/docs/spec/runtime.md +251 -0
- package/docs/spec/session.md +380 -0
- package/docs/spec/ship/S0001-docs-baseline.md +41 -0
- package/docs/spec/ship/S0002-package-skeleton.md +56 -0
- package/docs/spec/ship/S0003-protocol-contracts.md +49 -0
- package/docs/spec/ship/S0004-session-core.md +50 -0
- package/docs/spec/ship/S0005-runtime-loop.md +48 -0
- package/docs/spec/ship/S0006-embedded-daemon-client.md +51 -0
- package/docs/spec/ship/S0007-cli-alpha.md +49 -0
- package/docs/spec/ship/S0008-coding-tools.md +107 -0
- package/docs/spec/ship/S0009-code-discovery-tools.md +82 -0
- package/docs/spec/ship/S0010-todo-tool-and-cli.md +81 -0
- package/docs/spec/ship/S0011-coding-agent-alpha-smoke.md +110 -0
- package/docs/spec/ship/S0012-coding-tools-maturity.md +143 -0
- package/docs/spec/ship/S0013-local-daemon-protocol.md +57 -0
- package/docs/spec/ship/S0014-local-daemon-lifecycle.md +64 -0
- package/docs/spec/ship/S0015-local-attach-and-broadcast.md +58 -0
- package/docs/spec/ship/S0016-local-daemon-resync-smoke.md +60 -0
- package/docs/spec/ship/S0017-grep-files-output-mode.md +49 -0
- package/docs/spec/ship/S0018-daemon-entrypoint-smoke.md +48 -0
- package/docs/spec/ship/S0019-remote-transport-contract.md +59 -0
- package/docs/spec/ship/S0020-remote-websocket-server.md +56 -0
- package/docs/spec/ship/S0021-remote-websocket-client-transport.md +55 -0
- package/docs/spec/ship/S0022-remote-daemon-cli-lifecycle.md +60 -0
- package/docs/spec/ship/S0023-remote-control-e2e-validation.md +66 -0
- package/docs/spec/ship/S0024-remote-attach-interactive-stream.md +49 -0
- package/docs/spec/ship/S0025-remote-attach-session-event-view.md +57 -0
- package/docs/spec/ship/S0026-attach-project-cache-and-dual-seq-reconnect.md +87 -0
- package/docs/spec/ship/S0027-session-diagnostics-log.md +77 -0
- package/docs/spec/ship/S0028-client-attach-diagnostics-log.md +70 -0
- package/docs/spec/ship/S0029-project-index-for-session-lookup.md +119 -0
- package/docs/spec/ship/S0030-webui-product-intent.md +73 -0
- package/docs/spec/ship/S0031-daemon-projectslug-rule.md +72 -0
- package/docs/spec/ship/S0032-daemon-protocol-completion.md +123 -0
- package/docs/spec/ship/S0033-webui-skeleton-routing.md +92 -0
- package/docs/spec/ship/S0034-webui-device-settings.md +121 -0
- package/docs/spec/ship/S0035-webui-device-handshake.md +83 -0
- package/docs/spec/ship/S0036-webui-project-session-sync.md +70 -0
- package/docs/spec/ship/S0037-webui-chatbox-v1.md +97 -0
- package/docs/spec/ship/S0038-webui-cancel-multiclient.md +65 -0
- package/docs/spec/ship/S0039-webui-e2e-newchat.md +74 -0
- package/docs/spec/ship/S0040-webui-codex-visual-tokens.md +227 -0
- package/docs/spec/ship/S0041-webui-markdown-and-tool-block.md +248 -0
- package/docs/spec/ship/S0042-webui-streaming-ux-autoscroll.md +130 -0
- package/docs/spec/ship/S0043-startup-ergonomics.md +278 -0
- package/docs/spec/ship/S0044-webui-chatbox-rebuild.md +556 -0
- package/docs/spec/ship/S0045-webui-card-sidebar-and-session-fixes.md +469 -0
- package/docs/spec/ship/S0046-webui-empty-composer-and-lazy-session.md +428 -0
- package/docs/spec/ship/S0047-webui-project-hover-newchat-and-dynamic-greeting.md +176 -0
- package/docs/spec/ship/S0048-device-level-host-project-registry.md +253 -0
- package/docs/spec/ship/S0049-webui-add-project-directory-browser.md +217 -0
- package/docs/spec/ship/S0050-instruction-snapshot-and-agents-assembly.md +338 -0
- package/docs/spec/ship/S0051-harness-item-and-system-reminder.md +190 -0
- package/docs/spec/ship/S0052-follow-up-queue-and-dual-loop.md +195 -0
- package/docs/spec/ship/S0053-skill-index-and-skill-tool.md +252 -0
- package/docs/spec/ship/S0054-webui-running-message-behavior.md +72 -0
- package/docs/spec/ship/S0055-webui-composer-acceptance-and-queue-strip.md +68 -0
- package/docs/spec/ship/S0056-relay-and-hosted-webui-contract.md +106 -0
- package/docs/spec/ship/S0057-relay-service-protocol-skeleton.md +161 -0
- package/docs/spec/ship/S0058-host-outbound-relay-and-pair-command.md +138 -0
- package/docs/spec/ship/S0059-relay-transport-and-hosted-webui-connector.md +140 -0
- package/docs/spec/ship/S0060-relay-hosted-webui-e2e-validation.md +132 -0
- package/docs/spec/ship/S0060-relay-hosted-webui-e2e-validation.verification.md +90 -0
- package/docs/spec/ship/S0061-hosted-defaults-and-cli-command-surface.md +208 -0
- package/docs/spec/ship/S0062-npm-package-and-release-workflow.md +166 -0
- package/docs/spec/tools.md +173 -0
- package/package.json +51 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# S0045: WebUI Card-Style Sidebar + Session Page Cleanup + Transport Error Guard
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Resolve the three blockers surfaced after S0044 ship:
|
|
6
|
+
|
|
7
|
+
1. **Visual iteration** — collapse the sidebar to a single card-style segment with click-to-toggle project/device rows (no ▸/▾ chevrons), per-session relative-time hints, and selection-by-row highlight.
|
|
8
|
+
2. **Session page cleanup** — delete `SessionHeader` and the `Chatbox` card outer shell. Transcript flows directly against the main `bg-bg`.
|
|
9
|
+
3. **Transport error guard** — ensure the `WsTransport is not connected` synchronous throw can never escape into React's render or effect path. Stale-token / disconnected scenarios degrade gracefully, no Next.js dev overlay.
|
|
10
|
+
|
|
11
|
+
Locked decisions live in `self/discussions/2026-05-31-s0045-webui-card-and-fixes-brainstorm.md` §5. Verification context in `self/discussions/2026-05-31-s0044-webui-chatbox-rebuild-verification.md`. Visual reference: user-supplied screenshot (single light-gray sidebar card, no row borders, clicking a project folds its sessions, selected session row gets a darker highlight, sessions show right-aligned relative time hints like `3 周` / `刚刚`).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Scope
|
|
16
|
+
|
|
17
|
+
### 1. Sidebar — single card segment
|
|
18
|
+
|
|
19
|
+
`apps/webui/components/shell/sidebar.tsx`:
|
|
20
|
+
|
|
21
|
+
- Remove the bottom `border-t border-subtle p-3` divider above Settings — the entire `<aside>` is one visual block on `bg-surface`.
|
|
22
|
+
- Top row group, devices group, and bottom row group rely on `space-y-*` and section padding for separation. No internal borders.
|
|
23
|
+
- Bottom group structure:
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
<div className="px-3 pb-3 pt-2 space-y-1">
|
|
27
|
+
<SettingsLink />
|
|
28
|
+
<DisabledRow icon="☀" label="主题" />
|
|
29
|
+
</div>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- Top group keeps `+ 新对话` (active when route is `/`) + 3 disabled rows. No structural change beyond removing any leftover borders.
|
|
33
|
+
|
|
34
|
+
`DeviceTree` row (currently `<Link href={device.url}>`):
|
|
35
|
+
|
|
36
|
+
- Replace with `<button type="button" onClick={toggleDevice}>` — **no route navigation**. The button toggles the device's collapsed state.
|
|
37
|
+
- Keep `aria-expanded={!collapsed}` and `aria-controls`.
|
|
38
|
+
- Keep `DeviceStatus` dot on the right.
|
|
39
|
+
- `offline` state styling unchanged.
|
|
40
|
+
- The `aria-current="page"` logic for active device row is removed; sessions inside drive active state visually via `SessionNode`.
|
|
41
|
+
- Drop the leftover `border-l-2 px-2 py-1.5` styling. New base class:
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm font-medium text-text hover:bg-surface-hover ${
|
|
45
|
+
collapsed ? "" : ""
|
|
46
|
+
}`}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
(no active state on device row; expansion alone is the visual)
|
|
50
|
+
|
|
51
|
+
`ProjectNode` row (`apps/webui/components/shell/project-node.tsx`):
|
|
52
|
+
|
|
53
|
+
- Same swap: `<Link href>` → `<button type="button" onClick={toggleProject}>`. **No route navigation.**
|
|
54
|
+
- Keep the existing `sessionCount` rendering (right-aligned `text-xs text-faint`); when `sessionCount` is undefined fall back to `Object.keys(project.sessions ?? {}).length` so users still see a count.
|
|
55
|
+
- Drop the `border-l-2 border-accent bg-accent-soft` active styling — projects no longer have an "active" concept since clicking doesn't navigate.
|
|
56
|
+
- Keep the `onSelect` callback hook, but rename / refocus: it now fires on **expansion** (not collapse), and its purpose is still to lazy-trigger `syncSessions`. Wrap-up: `onSelect` fires whenever the user expands a project that hasn't loaded sessions yet, and only when not offline.
|
|
57
|
+
|
|
58
|
+
Concrete behaviour:
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
const [collapsed, toggle] = useCollapsed(`project:${deviceId}/${projectSlug}`);
|
|
62
|
+
const sessions = sortSessions(project.sessions);
|
|
63
|
+
const handleClick = (): void => {
|
|
64
|
+
if (collapsed && !offline) onSelect?.(deviceId, projectSlug);
|
|
65
|
+
toggle();
|
|
66
|
+
};
|
|
67
|
+
return (
|
|
68
|
+
<li>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={handleClick}
|
|
72
|
+
aria-expanded={!collapsed}
|
|
73
|
+
className="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-sm text-text hover:bg-surface-hover"
|
|
74
|
+
>
|
|
75
|
+
<span className="truncate">{project.displayName ?? project.projectSlug}</span>
|
|
76
|
+
{sessionCount !== undefined && (
|
|
77
|
+
<span className="shrink-0 text-xs text-faint">{sessionCount}</span>
|
|
78
|
+
)}
|
|
79
|
+
</button>
|
|
80
|
+
{!collapsed && sessions.length > 0 && (
|
|
81
|
+
<ul className="ml-2 mt-0.5 space-y-0.5">
|
|
82
|
+
{sessions.map((session) => (
|
|
83
|
+
<SessionNode … />
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
)}
|
|
87
|
+
</li>
|
|
88
|
+
);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- The `border-l border-subtle pl-2` previously decorating the session sub-list goes away — visual hierarchy is purely indentation (`ml-2`).
|
|
92
|
+
|
|
93
|
+
`CollapseToggle` (`apps/webui/components/shell/collapse-toggle.tsx`):
|
|
94
|
+
|
|
95
|
+
- Component no longer rendered anywhere.
|
|
96
|
+
- **Delete** the file plus its test (`collapse-toggle.test.tsx`) — toggle logic now lives inline in `ProjectNode` / `DeviceTree`. The hook `useCollapsed` (`lib/store/use-collapsed.ts`) stays exactly as is.
|
|
97
|
+
|
|
98
|
+
`Sidebar` shell wrapper (`<aside>`) keeps `w-[280px] shrink-0 bg-surface flex flex-col`. No border-r anymore — the warm-paper era left a `border-r border-subtle` on it; current S0044 already dropped it. Verify with grep.
|
|
99
|
+
|
|
100
|
+
### 2. SessionNode — relative time hint
|
|
101
|
+
|
|
102
|
+
`apps/webui/components/shell/session-node.tsx`:
|
|
103
|
+
|
|
104
|
+
- Add `formatRelativeTime(updatedAt: number, now: number): string` (Chinese strings):
|
|
105
|
+
- `now - updatedAt < 60_000` → `刚刚`
|
|
106
|
+
- `< 3_600_000` → `${Math.floor(diff/60_000)} 分钟`
|
|
107
|
+
- `< 86_400_000` → `${Math.floor(diff/3_600_000)} 小时`
|
|
108
|
+
- `< 604_800_000` → `${Math.floor(diff/86_400_000)} 天`
|
|
109
|
+
- `< 2_592_000_000` → `${Math.floor(diff/604_800_000)} 周`
|
|
110
|
+
- `< 31_536_000_000` → `${Math.floor(diff/2_592_000_000)} 个月`
|
|
111
|
+
- else → `${Math.floor(diff/31_536_000_000)} 年`
|
|
112
|
+
- `updatedAt` undefined or NaN → return empty string (caller hides hint).
|
|
113
|
+
- Place the helper in `apps/webui/lib/format/relative-time.ts` (new file) so it's testable in isolation and SSR-safe.
|
|
114
|
+
- Component:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
"use client";
|
|
118
|
+
|
|
119
|
+
import Link from "next/link";
|
|
120
|
+
import { useEffect, useState } from "react";
|
|
121
|
+
import { formatRelativeTime } from "../../lib/format/relative-time";
|
|
122
|
+
import type { DeviceSessionSummary } from "../../lib/domain/devices";
|
|
123
|
+
|
|
124
|
+
export function SessionNode({ deviceId, projectSlug, session, isActive }: SessionNodeProps): JSX.Element {
|
|
125
|
+
const href = `/devices/${encodeURIComponent(deviceId)}/projects/${encodeURIComponent(projectSlug)}/sessions/${encodeURIComponent(session.sessionId)}`;
|
|
126
|
+
const label = session.title?.trim() || session.sessionId;
|
|
127
|
+
const [now, setNow] = useState<number | null>(null);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
setNow(Date.now());
|
|
130
|
+
const id = setInterval(() => setNow(Date.now()), 60_000);
|
|
131
|
+
return () => clearInterval(id);
|
|
132
|
+
}, []);
|
|
133
|
+
const hint = now !== null && session.updatedAt
|
|
134
|
+
? formatRelativeTime(session.updatedAt, now)
|
|
135
|
+
: "";
|
|
136
|
+
return (
|
|
137
|
+
<li>
|
|
138
|
+
<Link
|
|
139
|
+
href={href}
|
|
140
|
+
aria-current={isActive ? "page" : undefined}
|
|
141
|
+
className={`flex items-center justify-between gap-2 rounded-sm px-2 py-1 text-xs hover:bg-surface-hover ${
|
|
142
|
+
isActive ? "bg-surface-hover font-medium text-text" : "text-muted"
|
|
143
|
+
}`}
|
|
144
|
+
title={label}
|
|
145
|
+
>
|
|
146
|
+
<span className="truncate">{label}</span>
|
|
147
|
+
{hint ? <span className="shrink-0 text-faint">{hint}</span> : null}
|
|
148
|
+
</Link>
|
|
149
|
+
</li>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- SSR phase: `now === null` → no hint rendered. Avoids hydration mismatch from `Date.now()`.
|
|
155
|
+
- Cleanup `clearInterval` on unmount.
|
|
156
|
+
|
|
157
|
+
### 3. Session page cleanup
|
|
158
|
+
|
|
159
|
+
`apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/page.tsx`:
|
|
160
|
+
|
|
161
|
+
- Delete the `SessionHeader` function (lines 250-299) and remove its call from `SessionView`.
|
|
162
|
+
- `SessionView` outer container changes from `<div className="flex h-full flex-col gap-3 p-6 text-sm text-text">` → `<div className="flex h-full flex-col text-sm text-text">`.
|
|
163
|
+
- Move the metadata-loading inline notice (when `error` is set) to render **inside** the new layout, above the chatbox, with no `border` / `bg-surface-raised` card:
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
{error && (
|
|
167
|
+
<p className="px-6 pt-4 text-sm text-status-err">
|
|
168
|
+
Failed to load session metadata: {error}
|
|
169
|
+
</p>
|
|
170
|
+
)}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- Same for the `!remoteDeviceId` "Connecting to daemon…" notice — replace the dashed-border card with:
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
<p className="flex h-full items-center justify-center text-sm text-muted">
|
|
177
|
+
Connecting to daemon… (waiting for device identity)
|
|
178
|
+
</p>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- `Chatbox` container outer div changes from:
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
<div className="flex h-[60vh] min-h-[400px] flex-col overflow-hidden rounded-md border border-subtle bg-surface">
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
to:
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
<div className="flex h-full flex-col overflow-hidden">
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
(no card, no border, no fixed height — fills its parent.)
|
|
194
|
+
|
|
195
|
+
- `ChatboxBody` keeps the inline `snapshot.error` notice but drops the outer `bg-surface-raised`:
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
{snapshot.error && (
|
|
199
|
+
<p className="px-6 py-2 text-xs text-status-err">
|
|
200
|
+
{snapshot.error.reason}: {snapshot.error.message}
|
|
201
|
+
</p>
|
|
202
|
+
)}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- Composer renders directly under the transcript without any wrapping border.
|
|
206
|
+
|
|
207
|
+
### 4. Transport error guard
|
|
208
|
+
|
|
209
|
+
#### 4.1 Map sync throws to rejections
|
|
210
|
+
|
|
211
|
+
`packages/client/src/index.ts`:
|
|
212
|
+
|
|
213
|
+
Audit every public method that funnels through `WsTransport.#write` (which throws synchronously when `socket.readyState !== OPEN`). Each public async method must wrap any synchronous part in `try { … } catch (e) { return Promise.reject(toTransportError(e)); }`. Add helper:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
class TransportDisconnectedError extends Error {
|
|
217
|
+
readonly code = "transport_disconnected" as const;
|
|
218
|
+
constructor(message: string) {
|
|
219
|
+
super(message);
|
|
220
|
+
this.name = "TransportDisconnectedError";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function toTransportError(cause: unknown): TransportDisconnectedError {
|
|
225
|
+
if (cause instanceof TransportDisconnectedError) return cause;
|
|
226
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
227
|
+
return new TransportDisconnectedError(message);
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Concretely:
|
|
232
|
+
|
|
233
|
+
- `RemoteSessionClient.sendMessage` / `cancel` / `connect` / `resync` / `listSessions` / `listProjects` / `handshake` (or whatever the public surface is — match the existing exported signatures): each one's first line `this.transport.write(...)` is wrapped. If the method already does `await ...`, wrap the entire body. If it returns a Promise built from a deferred object, ensure the deferred is rejected on synchronous throw.
|
|
234
|
+
|
|
235
|
+
Audit list at implementation time: search `packages/client/src/**` for `transport.write(` and ensure every caller either is already inside a Promise body that catches sync throws, or gets a wrapper.
|
|
236
|
+
|
|
237
|
+
**Do not change the `WsTransport.#write` semantics** — keeping it sync-throw simplifies internal use. The wrapper sits at the public client boundary.
|
|
238
|
+
|
|
239
|
+
Add unit tests in `packages/client/src/index.test.ts` (or wherever existing client tests live):
|
|
240
|
+
|
|
241
|
+
- "sendMessage when transport is closed rejects with code transport_disconnected".
|
|
242
|
+
- "cancel when transport is closed rejects with code transport_disconnected".
|
|
243
|
+
- "resync when transport is closed rejects with code transport_disconnected".
|
|
244
|
+
- The throw is awaited (no synchronous escape).
|
|
245
|
+
|
|
246
|
+
#### 4.2 Map error in webui
|
|
247
|
+
|
|
248
|
+
`apps/webui/lib/connection/session.ts`:
|
|
249
|
+
|
|
250
|
+
- In each existing `catch (cause)` block (inside `start`, `send`, `cancel`), detect `cause?.code === "transport_disconnected"` and pick a stable reason string `"disconnected"` instead of the generic reason. Schema:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
type SessionError =
|
|
254
|
+
| { reason: "resync_failed"; message: string }
|
|
255
|
+
| { reason: "send_failed"; message: string }
|
|
256
|
+
| { reason: "cancel_failed"; message: string }
|
|
257
|
+
| { reason: "disconnected"; message: string };
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Update the type alias accordingly. Map:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
function classifyError(cause: unknown, fallback: SessionError["reason"]): SessionError {
|
|
264
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
265
|
+
const code = (cause as { code?: string })?.code;
|
|
266
|
+
if (code === "transport_disconnected") {
|
|
267
|
+
return { reason: "disconnected", message };
|
|
268
|
+
}
|
|
269
|
+
return { reason: fallback, message };
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Use in each catch:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
catch (cause) {
|
|
277
|
+
error = classifyError(cause, "resync_failed");
|
|
278
|
+
// ...
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
- UI rendering of `snapshot.error` (in session page `ChatboxBody`) prefers a clearer message when reason is `"disconnected"`:
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
{snapshot.error && (
|
|
286
|
+
<p className="px-6 py-2 text-xs text-status-err">
|
|
287
|
+
{snapshot.error.reason === "disconnected"
|
|
288
|
+
? "连接已断开。检查 daemon token 后刷新页面。"
|
|
289
|
+
: `${snapshot.error.reason}: ${snapshot.error.message}`}
|
|
290
|
+
</p>
|
|
291
|
+
)}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
- `Composer` `errorBanner` follows the same pattern when `cancel_failed` shows up; no UX change.
|
|
295
|
+
|
|
296
|
+
`apps/webui/lib/sync/sessions.ts`:
|
|
297
|
+
|
|
298
|
+
- Wrap `client.list_sessions` / `list_projects` calls likewise; map `transport_disconnected` to `setSessionsSyncError(deviceId, projectSlug, "disconnected: " + message)`. The existing best-effort `.catch(() => {})` in `sidebar.tsx` line 117-120 is fine, but the global error map needs the disconnect signal so the project-list page banner can surface it.
|
|
299
|
+
|
|
300
|
+
`apps/webui/lib/connection/use-connection.ts`:
|
|
301
|
+
|
|
302
|
+
- When the connection state machine transitions to `error` with `reason === "auth"` (token rejected) keep the existing reconnect cooldown / retry-cap logic. Add a sub-case: if reconnect attempts produce repeated `transport_disconnected` after an `auth` failure, **stop reconnecting** and stay in `error` with a clear message. Surface it via the existing `state.message` field.
|
|
303
|
+
- No new connection state machine state; just guarantee no infinite retry loop.
|
|
304
|
+
|
|
305
|
+
#### 4.3 Global ErrorBoundary
|
|
306
|
+
|
|
307
|
+
`apps/webui/app/error.tsx` (new file — Next.js App Router error boundary):
|
|
308
|
+
|
|
309
|
+
```tsx
|
|
310
|
+
"use client";
|
|
311
|
+
|
|
312
|
+
import { useEffect } from "react";
|
|
313
|
+
|
|
314
|
+
export default function GlobalError({
|
|
315
|
+
error,
|
|
316
|
+
reset,
|
|
317
|
+
}: {
|
|
318
|
+
error: Error & { digest?: string };
|
|
319
|
+
reset: () => void;
|
|
320
|
+
}): JSX.Element {
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
// eslint-disable-next-line no-console
|
|
323
|
+
console.error("[scorel] unhandled UI error:", error);
|
|
324
|
+
}, [error]);
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
|
|
328
|
+
<h1 className="greeting">出错了</h1>
|
|
329
|
+
<p className="text-md text-muted">
|
|
330
|
+
发生意外错误。已记录到控制台。
|
|
331
|
+
</p>
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
onClick={reset}
|
|
335
|
+
className="rounded-pill bg-accent px-5 py-2 text-bg hover:bg-accent-hover"
|
|
336
|
+
>
|
|
337
|
+
重新加载
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Add per-route error boundary at `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/error.tsx` with the same template scoped to the session route — the global one catches anything escaping route layout.
|
|
345
|
+
|
|
346
|
+
### 5. Tests
|
|
347
|
+
|
|
348
|
+
**New**:
|
|
349
|
+
|
|
350
|
+
- `apps/webui/lib/format/relative-time.test.ts` — every threshold branch; undefined/NaN returns empty.
|
|
351
|
+
- `apps/webui/components/shell/session-node.test.tsx` — extend or create:
|
|
352
|
+
- Renders the relative-time hint after mount (use vitest fake timers + `vi.useFakeTimers()`).
|
|
353
|
+
- SSR-style first render (before useEffect) shows no hint.
|
|
354
|
+
- Active class set when `isActive`.
|
|
355
|
+
- Extend `apps/webui/components/shell/sidebar.test.tsx`:
|
|
356
|
+
- Click a project row → the row's button fires `onClick` and toggles collapse; **no navigation triggered** (assert `useRouter.push` not called or assert no `<a href>` on project row).
|
|
357
|
+
- Click a session row → still navigates (existing behavior).
|
|
358
|
+
- Confirm device row is now a `<button>` not a `<Link>`.
|
|
359
|
+
- Confirm sidebar has no `border-t` / `border-r` element nested inside.
|
|
360
|
+
- Extend `apps/webui/components/shell/project-node.test.tsx`:
|
|
361
|
+
- On expand from collapsed state, fires `onSelect(deviceId, projectSlug)`.
|
|
362
|
+
- On collapse, does NOT fire `onSelect`.
|
|
363
|
+
- When `offline === true`, `onSelect` not fired even on expand.
|
|
364
|
+
- New `packages/client/src/transport-error.test.ts` (or extend existing client tests) — see §4.1.
|
|
365
|
+
- Extend `apps/webui/lib/connection/session.test.ts` if it exists (else add `session.test.ts`):
|
|
366
|
+
- When `client.sendMessage` rejects with `code: "transport_disconnected"`, snapshot.error.reason is `"disconnected"`.
|
|
367
|
+
- Same for `client.cancel`, `client.resync`.
|
|
368
|
+
|
|
369
|
+
**Modified**:
|
|
370
|
+
|
|
371
|
+
- `apps/webui/components/shell/sidebar.test.tsx` — adjust assertions that previously depended on `<Link href={deviceUrl}>` for device row.
|
|
372
|
+
- `apps/webui/components/shell/project-node.test.tsx` — same.
|
|
373
|
+
- `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/page.test.tsx` if it exists — drop SessionHeader assertions; assert no `h1` present in chatbox region; assert no `bg-surface` card outer.
|
|
374
|
+
|
|
375
|
+
**Deleted**:
|
|
376
|
+
|
|
377
|
+
- `apps/webui/components/shell/collapse-toggle.tsx`
|
|
378
|
+
- `apps/webui/components/shell/collapse-toggle.test.tsx`
|
|
379
|
+
|
|
380
|
+
### 6. Boundary tests
|
|
381
|
+
|
|
382
|
+
`apps/webui/src/package-boundaries.test.ts`:
|
|
383
|
+
|
|
384
|
+
- Existing palette ban + `font-display` ban remain.
|
|
385
|
+
- Add: scan source files and fail if `<CollapseToggle` JSX or `from ".*collapse-toggle"` import survives. (Cheap regex over `apps/webui/{app,components}/**/*.tsx` excluding test files.)
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
## Not In Scope
|
|
390
|
+
|
|
391
|
+
- Real keyboard shortcuts ⌘1..9 to jump between sessions.
|
|
392
|
+
- Truncating the session list with a "展开显示" button after N items.
|
|
393
|
+
- Dark mode implementation.
|
|
394
|
+
- Cmd+B full sidebar collapse to 56px.
|
|
395
|
+
- Composer model picker real switching.
|
|
396
|
+
- Daemon protocol changes (all fixes are local to client + webui).
|
|
397
|
+
- Project overview page redesign — the route is preserved (typed URL still loads it) but no longer reachable from the sidebar.
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## Acceptance Criteria
|
|
402
|
+
|
|
403
|
+
1. Sidebar has no internal borders / dividers anywhere; visual hierarchy comes from `space-y-*` spacing and hover/active background changes only.
|
|
404
|
+
2. Project / Device rows are `<button>` elements that toggle collapse on click. No route navigation. Tested in `sidebar.test.tsx`.
|
|
405
|
+
3. Session row stays the only entry to a session. Tested.
|
|
406
|
+
4. `<CollapseToggle>` component and its test file are deleted; no source imports it.
|
|
407
|
+
5. Project row shows `sessionCount` (or fallback derived count) right-aligned `text-xs text-faint`.
|
|
408
|
+
6. Session row shows `formatRelativeTime` hint right-aligned `text-xs text-faint`. Hint is empty when `session.updatedAt` is missing/invalid.
|
|
409
|
+
7. Hint refreshes once per minute and clears its interval on unmount. Verified by `session-node.test.tsx`.
|
|
410
|
+
8. SessionHeader function and call site removed. No `<h1>` rendered in the session route layout above the transcript.
|
|
411
|
+
9. Chatbox container does not use `h-[60vh]`, `min-h-[400px]`, `rounded-md`, `border-subtle`, or `bg-surface` on its outermost wrapper. Transcript and Composer flow directly inside `flex h-full flex-col` against the main `bg-bg`.
|
|
412
|
+
10. `SessionView` outer container drops the `gap-3 p-6` padding wrapper; inline notices use `px-6` for indentation only.
|
|
413
|
+
11. `packages/client/src/index.ts` public methods (`sendMessage`, `cancel`, `connect`, `resync`, `listSessions`, `listProjects`, `handshake` and any other public path that hits `transport.write`) catch synchronous throws and return rejected promises with `code: "transport_disconnected"`.
|
|
414
|
+
12. New `TransportDisconnectedError` class is exported from `@scorel/client` so consumers can `instanceof` check it.
|
|
415
|
+
13. `apps/webui/lib/connection/session.ts` `SessionError` type now includes `"disconnected"` reason. All catch blocks classify via `classifyError`.
|
|
416
|
+
14. `ChatboxBody` renders the friendly "连接已断开。检查 daemon token 后刷新页面。" message when `snapshot.error.reason === "disconnected"`.
|
|
417
|
+
15. `apps/webui/app/error.tsx` and `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/error.tsx` exist and render the greeting + reload button template.
|
|
418
|
+
16. `pnpm --filter @scorel/app-webui typecheck && pnpm --filter @scorel/app-webui test && pnpm --filter @scorel/app-webui build` all green.
|
|
419
|
+
17. Repo-level `pnpm typecheck && pnpm test` green.
|
|
420
|
+
18. Manual e2e by user (out of opus subagent scope; record results in a follow-up verification doc):
|
|
421
|
+
- Stale-token reproducer: edit `~/.scorel/daemon.json` to replace the token with garbage, refresh `/`. **Expect**: no Next.js dev overlay, no red modal. Sidebar shows the device offline; opening a cached session shows the friendly disconnected message inline.
|
|
422
|
+
- Card sidebar: matches the user-supplied screenshot (single-block light gray, no internal borders, click project to fold sessions, hover row highlights, selected session row highlight slightly darker).
|
|
423
|
+
- Relative time hint visible on every session row with `updatedAt`.
|
|
424
|
+
- Click a project row: sessions fold/unfold; URL does not change.
|
|
425
|
+
- Click a session row: navigates to `/devices/.../sessions/...`.
|
|
426
|
+
- Session route shows transcript flush against bg, composer at bottom pill, no header.
|
|
427
|
+
- CLI `scorel attach` to the same session keeps streaming in sync.
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## Affected Paths
|
|
432
|
+
|
|
433
|
+
- `apps/webui/components/shell/sidebar.tsx`
|
|
434
|
+
- `apps/webui/components/shell/project-node.tsx`
|
|
435
|
+
- `apps/webui/components/shell/project-node.test.tsx`
|
|
436
|
+
- `apps/webui/components/shell/session-node.tsx`
|
|
437
|
+
- `apps/webui/components/shell/session-node.test.tsx` *(new or modified)*
|
|
438
|
+
- `apps/webui/components/shell/collapse-toggle.tsx` — **deleted**
|
|
439
|
+
- `apps/webui/components/shell/collapse-toggle.test.tsx` — **deleted**
|
|
440
|
+
- `apps/webui/components/shell/sidebar.test.tsx`
|
|
441
|
+
- `apps/webui/lib/format/relative-time.ts` — **new**
|
|
442
|
+
- `apps/webui/lib/format/relative-time.test.ts` — **new**
|
|
443
|
+
- `apps/webui/lib/connection/session.ts`
|
|
444
|
+
- `apps/webui/lib/connection/use-connection.ts`
|
|
445
|
+
- `apps/webui/lib/sync/sessions.ts`
|
|
446
|
+
- `apps/webui/lib/connection/session.test.ts` (extended, or created if absent)
|
|
447
|
+
- `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/page.tsx`
|
|
448
|
+
- `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/error.tsx` — **new**
|
|
449
|
+
- `apps/webui/app/error.tsx` — **new**
|
|
450
|
+
- `apps/webui/src/package-boundaries.test.ts`
|
|
451
|
+
- `packages/client/src/index.ts`
|
|
452
|
+
- `packages/client/src/index.test.ts` (or `transport-error.test.ts` — existing test file pattern)
|
|
453
|
+
- `packages/client/src/index.ts` exports updated (export `TransportDisconnectedError`)
|
|
454
|
+
- `docs/ROADMAP.md` — append M5.7.2 entry + S0045 row, mark Done after ship
|
|
455
|
+
- `apps/webui/README.md` (if it explains sidebar interactions, update)
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
## Risks And Boundaries
|
|
460
|
+
|
|
461
|
+
- **Project overview page accessibility**: removing the project row link cuts the only sidebar path to `/devices/:id/projects/:slug`. The route stays reachable via direct URL. If users complain we add a small "open project" sub-row in a follow-up spec.
|
|
462
|
+
- **`onSelect` semantics flip**: previously fired on every link click; now only fires on expand. Make sure `syncSessions` is still triggered the first time a project is opened. Mitigation: when `expanded === true` from initial state and `sessions === undefined`, fire `onSelect` once on mount.
|
|
463
|
+
- **setInterval per session row**: 1 timer per visible row. Typical < 100, fine. If we ever render hundreds of sessions, replace with a single global tick + context.
|
|
464
|
+
- **Hydration mismatch**: `Date.now()` differs between server render and client render. Solved by `useState<number | null>(null)` + `useEffect` initial set; first paint shows no hint, subsequent paints update.
|
|
465
|
+
- **Transport-error wrapper**: wrapping every public method must not change the existing rejection paths (most rejections come from the daemon over the wire and shouldn't get reclassified as `transport_disconnected`). The check is strict: only synchronous throws from `transport.write` get the special code. Daemon-side `error` responses keep their existing reason.
|
|
466
|
+
- **Per-route error boundaries**: Next 14 App Router has subtle hydration differences between `app/error.tsx` (segment scope) and `app/global-error.tsx` (full root). We use segment scope; if the layout itself throws, the existing fallback shows.
|
|
467
|
+
- **Deleting `CollapseToggle`**: no other consumer should reference it; the boundary test catches stragglers.
|
|
468
|
+
- **Single-PR scope**: S0045 touches sidebar + session page + client error mapping + new tests. Keep one commit `S0045: feat: card-style sidebar + session cleanup + transport guard`.
|
|
469
|
+
- **Sunk cost from S0044**: this iteration is the second pass on the same UI in 24 hours. Accept it; the structure (tokens, three-segment shell, pill composer, bubble shape) all survive — only sub-component shapes change.
|