@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,428 @@
|
|
|
1
|
+
# S0046: WebUI Empty-State Composer + Lazy Session Creation
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Convert the populated empty-state surface (`/`, `/devices/:id`, `/devices/:id/projects/:slug`) into a Codex-/Chatbox-style "central composer" landing: H1 prompt + large pill composer + project picker + mode/branch placeholders. Sidebar `+ 新对话` and the project-page `New Chat` button no longer create a session immediately — they navigate to the empty composer carrying device/project as query params. Session creation is deferred to the user's first `send`, eliminating empty-session sprawl.
|
|
6
|
+
|
|
7
|
+
Locked decisions: `self/discussions/2026-05-31-s0046-webui-empty-composer-brainstorm.md` §5. Visual reference: user-supplied screenshot (centered H1 "我们应该在 Scorel 中构建什么?", pill composer with `随心输入` placeholder, three pickers below: project / 本地模式 / main).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Scope
|
|
12
|
+
|
|
13
|
+
### 1. New empty-state composer component
|
|
14
|
+
|
|
15
|
+
`apps/webui/components/chatbox/empty-composer.tsx` — **new client component**:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
"use client";
|
|
19
|
+
|
|
20
|
+
import Link from "next/link";
|
|
21
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
22
|
+
import { useEffect, useMemo, useState } from "react";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
getConnectionPool,
|
|
26
|
+
getDevicesStoreInstance,
|
|
27
|
+
} from "../../lib/connection/use-connection";
|
|
28
|
+
import { createSessionForProject } from "../../lib/sync/session-create";
|
|
29
|
+
import { useDevices } from "../../lib/store/use-devices";
|
|
30
|
+
import {
|
|
31
|
+
readLastActiveProject,
|
|
32
|
+
writeLastActiveProject,
|
|
33
|
+
} from "../../lib/store/last-active-project";
|
|
34
|
+
import { Composer } from "./composer";
|
|
35
|
+
|
|
36
|
+
export type EmptyComposerProps = {
|
|
37
|
+
/** Defaults sourced from the route segment (when on
|
|
38
|
+
* `/devices/:id/projects/:slug`). The picker can override; URL `?device=`,
|
|
39
|
+
* `?project=` query string takes precedence over both. */
|
|
40
|
+
routeDeviceId?: string;
|
|
41
|
+
routeProjectSlug?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function EmptyComposer({
|
|
45
|
+
routeDeviceId,
|
|
46
|
+
routeProjectSlug,
|
|
47
|
+
}: EmptyComposerProps): JSX.Element {
|
|
48
|
+
const router = useRouter();
|
|
49
|
+
const search = useSearchParams();
|
|
50
|
+
const { devices } = useDevices();
|
|
51
|
+
const [error, setError] = useState<string | null>(null);
|
|
52
|
+
const [busy, setBusy] = useState(false);
|
|
53
|
+
|
|
54
|
+
// Resolve effective deviceId.
|
|
55
|
+
const deviceId = useMemo(() => {
|
|
56
|
+
const fromQuery = search?.get("device") ?? undefined;
|
|
57
|
+
return fromQuery || routeDeviceId || devices[0]?.id;
|
|
58
|
+
}, [search, routeDeviceId, devices]);
|
|
59
|
+
const device = devices.find((d) => d.id === deviceId);
|
|
60
|
+
const projects = device?.projects ?? [];
|
|
61
|
+
|
|
62
|
+
// Resolve effective projectSlug.
|
|
63
|
+
const projectSlug = useMemo(() => {
|
|
64
|
+
const fromQuery = search?.get("project") ?? undefined;
|
|
65
|
+
if (fromQuery) return fromQuery;
|
|
66
|
+
if (routeProjectSlug) return routeProjectSlug;
|
|
67
|
+
const last = readLastActiveProject(deviceId);
|
|
68
|
+
if (last && projects.find((p) => p.projectSlug === last)) return last;
|
|
69
|
+
return projects[0]?.projectSlug;
|
|
70
|
+
}, [search, routeProjectSlug, deviceId, projects]);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (deviceId && projectSlug) writeLastActiveProject(deviceId, projectSlug);
|
|
74
|
+
}, [deviceId, projectSlug]);
|
|
75
|
+
|
|
76
|
+
const handleProjectChange = (slug: string): void => {
|
|
77
|
+
const params = new URLSearchParams(search?.toString() ?? "");
|
|
78
|
+
params.set("project", slug);
|
|
79
|
+
if (deviceId) params.set("device", deviceId);
|
|
80
|
+
router.replace(`/?${params.toString()}`);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleSend = async (content: string): Promise<void> => {
|
|
84
|
+
setError(null);
|
|
85
|
+
if (!deviceId || !projectSlug) {
|
|
86
|
+
setError("先选择设备和项目");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const pool = getConnectionPool();
|
|
90
|
+
const client = pool.peekClient(deviceId);
|
|
91
|
+
if (!client) {
|
|
92
|
+
setError("设备未连接,先去 Settings 检查");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
setBusy(true);
|
|
96
|
+
try {
|
|
97
|
+
const { sessionId } = await createSessionForProject({
|
|
98
|
+
client,
|
|
99
|
+
store: getDevicesStoreInstance(),
|
|
100
|
+
deviceId,
|
|
101
|
+
projectSlug,
|
|
102
|
+
});
|
|
103
|
+
sessionStorage.setItem(`scorel.pending-prompt:${sessionId}`, content);
|
|
104
|
+
const target = `/devices/${encodeURIComponent(deviceId)}/projects/${encodeURIComponent(projectSlug)}/sessions/${encodeURIComponent(sessionId)}`;
|
|
105
|
+
router.push(target);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
108
|
+
} finally {
|
|
109
|
+
setBusy(false);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (devices.length === 0) {
|
|
114
|
+
return (
|
|
115
|
+
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
|
|
116
|
+
<h1 className="greeting">欢迎使用 Scorel</h1>
|
|
117
|
+
<p className="text-md text-muted">先添加一个设备开始</p>
|
|
118
|
+
<Link
|
|
119
|
+
href="/settings"
|
|
120
|
+
className="rounded-pill bg-accent px-5 py-2 text-bg hover:bg-accent-hover"
|
|
121
|
+
>
|
|
122
|
+
打开 Settings
|
|
123
|
+
</Link>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="flex h-full flex-col items-center justify-center px-4">
|
|
130
|
+
<div className="w-full max-w-3xl space-y-6">
|
|
131
|
+
<h1 className="greeting text-center">
|
|
132
|
+
我们应该在 Scorel 中构建什么?
|
|
133
|
+
</h1>
|
|
134
|
+
<Composer
|
|
135
|
+
onSend={handleSend}
|
|
136
|
+
inFlight={false}
|
|
137
|
+
placeholder="随心输入"
|
|
138
|
+
disabled={busy || !deviceId || !projectSlug}
|
|
139
|
+
errorBanner={error ?? undefined}
|
|
140
|
+
/>
|
|
141
|
+
<PickerRow
|
|
142
|
+
projects={projects}
|
|
143
|
+
activeSlug={projectSlug}
|
|
144
|
+
onProjectChange={handleProjectChange}
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function PickerRow({
|
|
152
|
+
projects,
|
|
153
|
+
activeSlug,
|
|
154
|
+
onProjectChange,
|
|
155
|
+
}: {
|
|
156
|
+
projects: { projectSlug: string; displayName?: string }[];
|
|
157
|
+
activeSlug: string | undefined;
|
|
158
|
+
onProjectChange: (slug: string) => void;
|
|
159
|
+
}): JSX.Element {
|
|
160
|
+
const single = projects.length <= 1;
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex items-center justify-center gap-3 text-sm">
|
|
163
|
+
<label className="flex items-center gap-1 text-muted">
|
|
164
|
+
<span aria-hidden>📁</span>
|
|
165
|
+
<select
|
|
166
|
+
value={activeSlug ?? ""}
|
|
167
|
+
onChange={(e) => onProjectChange(e.target.value)}
|
|
168
|
+
disabled={single}
|
|
169
|
+
aria-label="选择项目"
|
|
170
|
+
className="rounded-sm bg-transparent text-text outline-none focus-visible:outline-2 focus-visible:outline-text disabled:cursor-default"
|
|
171
|
+
>
|
|
172
|
+
{projects.map((p) => (
|
|
173
|
+
<option key={p.projectSlug} value={p.projectSlug}>
|
|
174
|
+
{p.displayName ?? p.projectSlug}
|
|
175
|
+
</option>
|
|
176
|
+
))}
|
|
177
|
+
</select>
|
|
178
|
+
</label>
|
|
179
|
+
<button type="button" disabled className="btn-disabled flex items-center gap-1 text-muted">
|
|
180
|
+
<span aria-hidden>💻</span>
|
|
181
|
+
<span>本地模式</span>
|
|
182
|
+
<span aria-hidden>▾</span>
|
|
183
|
+
</button>
|
|
184
|
+
<button type="button" disabled className="btn-disabled flex items-center gap-1 text-muted">
|
|
185
|
+
<span aria-hidden>⎇</span>
|
|
186
|
+
<span>main</span>
|
|
187
|
+
<span aria-hidden>▾</span>
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Key decisions encoded above:
|
|
195
|
+
|
|
196
|
+
- Native `<select>` for project picker (no Base UI Combobox in this spec).
|
|
197
|
+
- Empty-state inherits from URL query > route segment > localStorage > first available.
|
|
198
|
+
- `disabled` on Composer when `!deviceId || !projectSlug` so `send` button stays inert.
|
|
199
|
+
|
|
200
|
+
### 2. Composer prop change
|
|
201
|
+
|
|
202
|
+
`apps/webui/components/chatbox/composer.tsx`:
|
|
203
|
+
|
|
204
|
+
- Add `placeholder?: string` already exists. No change needed.
|
|
205
|
+
- `errorBanner` already supported.
|
|
206
|
+
- `inFlight` semantics: `EmptyComposer` always passes `false` since the in-flight state is owned post-create by the session page. The "creating session" feedback shows via `disabled` + the `errorBanner` if it fails.
|
|
207
|
+
|
|
208
|
+
Optional polish (within this spec): when `disabled` is true and the user attempts to type, no UX change required; the textarea simply doesn't accept focus.
|
|
209
|
+
|
|
210
|
+
### 3. Page integrations
|
|
211
|
+
|
|
212
|
+
`apps/webui/app/page.tsx`:
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
"use client";
|
|
216
|
+
|
|
217
|
+
import { EmptyComposer } from "../components/chatbox/empty-composer";
|
|
218
|
+
|
|
219
|
+
export default function HomePage(): JSX.Element {
|
|
220
|
+
return <EmptyComposer />;
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
(No props — `EmptyComposer` figures out everything from `useSearchParams` + `useDevices`.)
|
|
225
|
+
|
|
226
|
+
`apps/webui/app/devices/[deviceId]/page.tsx`:
|
|
227
|
+
|
|
228
|
+
- Currently lists projects. Replace **content above the project list** so the page renders an `EmptyComposer` (with `routeDeviceId={params.deviceId}`) and below it the project list / sessions index.
|
|
229
|
+
- Layout: `flex h-full flex-col` — top half centered EmptyComposer (via flex-grow), bottom half scrollable project listing — **simplification**: keep the existing project listing intact, but render `EmptyComposer` ABOVE only when route has no further segments. Since this page already shows projects, push the project listing into a smaller helper section beneath:
|
|
230
|
+
|
|
231
|
+
Actually, simpler path: for S0046 we **only convert `/`** to use EmptyComposer, since the `/devices/:id` and `/devices/:id/projects/:slug` pages already serve as project / session listings. The user's screenshot shows the empty composer at `/`. Inheriting `+ 新对话` route via query string is sufficient.
|
|
232
|
+
|
|
233
|
+
**Revised**: only `app/page.tsx` changes. `app/devices/[deviceId]/page.tsx` and `app/devices/[deviceId]/projects/[projectSlug]/page.tsx` stay as-is.
|
|
234
|
+
|
|
235
|
+
`+ 新对话` from any route navigates to `/?device=…&project=…` carrying inherited context. The user lands on `/` with the EmptyComposer pre-populated.
|
|
236
|
+
|
|
237
|
+
### 4. NewChatButton — rewrite as navigation
|
|
238
|
+
|
|
239
|
+
`apps/webui/components/shell/new-chat-button.tsx`:
|
|
240
|
+
|
|
241
|
+
Replace the entire `handleClick` body. New behavior:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
const router = useRouter();
|
|
245
|
+
const handleClick = (): void => {
|
|
246
|
+
const params = new URLSearchParams();
|
|
247
|
+
if (deviceId) params.set("device", deviceId);
|
|
248
|
+
if (projectSlug) params.set("project", projectSlug);
|
|
249
|
+
router.push(params.toString() ? `/?${params.toString()}` : "/");
|
|
250
|
+
};
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- Drop `createSession` prop (and the imported helper / pool / store from this file).
|
|
254
|
+
- Drop `creating` state, `error` state, the error banner.
|
|
255
|
+
- Drop the `tooltip = "Select a project first"` — the button is always enabled; missing context falls back gracefully (just push `/`).
|
|
256
|
+
- `disabled` only when there are zero devices configured (in which case sidebar shouldn't render it anyway, but defend).
|
|
257
|
+
- Test seam `createSession` prop removed; tests must adapt (assert router.push call).
|
|
258
|
+
|
|
259
|
+
`variant="page"` (used inside `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/page.tsx`) — same rewrite. The button still navigates to `/?device=…&project=…`.
|
|
260
|
+
|
|
261
|
+
### 5. Session-page pending-prompt consumption
|
|
262
|
+
|
|
263
|
+
`apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/page.tsx`:
|
|
264
|
+
|
|
265
|
+
In `Chatbox` component, after the controller is created and `snapshot.loading === false`, consume any pending prompt **once**:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
const consumedRef = useRef(false);
|
|
269
|
+
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (consumedRef.current) return;
|
|
272
|
+
if (snapshot.loading) return;
|
|
273
|
+
if (!controllerRef.current) return;
|
|
274
|
+
if (typeof window === "undefined") return;
|
|
275
|
+
const key = `scorel.pending-prompt:${sessionId}`;
|
|
276
|
+
const pending = window.sessionStorage.getItem(key);
|
|
277
|
+
if (!pending) return;
|
|
278
|
+
consumedRef.current = true;
|
|
279
|
+
window.sessionStorage.removeItem(key);
|
|
280
|
+
void controllerRef.current.send(pending).catch(() => {
|
|
281
|
+
// Error surfaces via snapshot.error; nothing else to do here.
|
|
282
|
+
});
|
|
283
|
+
}, [snapshot.loading, sessionId]);
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
- `consumedRef` ensures one-shot send even on subsequent renders.
|
|
287
|
+
- `snapshot.loading === false` proxies "controller fully ready" — same gate the existing UI uses to hide the loading spinner.
|
|
288
|
+
- Failure rendering already covered by existing `snapshot.error` UI.
|
|
289
|
+
|
|
290
|
+
### 6. localStorage helpers
|
|
291
|
+
|
|
292
|
+
`apps/webui/lib/store/last-active-project.ts` — **new**:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
const KEY = "scorel.ui.last-active-project";
|
|
296
|
+
|
|
297
|
+
type Map = Record<string, string>; // deviceId -> projectSlug
|
|
298
|
+
|
|
299
|
+
function read(): Map {
|
|
300
|
+
if (typeof window === "undefined") return {};
|
|
301
|
+
try {
|
|
302
|
+
return JSON.parse(window.localStorage.getItem(KEY) ?? "{}") as Map;
|
|
303
|
+
} catch {
|
|
304
|
+
return {};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function write(next: Map): void {
|
|
309
|
+
if (typeof window === "undefined") return;
|
|
310
|
+
window.localStorage.setItem(KEY, JSON.stringify(next));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function readLastActiveProject(deviceId: string | undefined): string | undefined {
|
|
314
|
+
if (!deviceId) return undefined;
|
|
315
|
+
return read()[deviceId];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function writeLastActiveProject(deviceId: string, projectSlug: string): void {
|
|
319
|
+
const map = read();
|
|
320
|
+
map[deviceId] = projectSlug;
|
|
321
|
+
write(map);
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### 7. Tests
|
|
326
|
+
|
|
327
|
+
**New**:
|
|
328
|
+
|
|
329
|
+
- `apps/webui/lib/store/last-active-project.test.ts` — read/write round-trip, JSON corruption fallback.
|
|
330
|
+
- `apps/webui/components/chatbox/empty-composer.test.tsx`:
|
|
331
|
+
- Renders H1, Composer, picker.
|
|
332
|
+
- Picker switching project triggers `router.replace` with `?device=&project=`.
|
|
333
|
+
- `handleSend` calls `createSessionForProject`, writes `sessionStorage`, calls `router.push` to session route.
|
|
334
|
+
- `handleSend` failure shows `errorBanner` and does not write sessionStorage.
|
|
335
|
+
- When `devices.length === 0` shows "先添加设备" CTA.
|
|
336
|
+
- `apps/webui/components/shell/new-chat-button.test.tsx` — adapt:
|
|
337
|
+
- click → `router.push("/?device=…&project=…")` when both context fields are present.
|
|
338
|
+
- click → `router.push("/")` when nothing is in context.
|
|
339
|
+
- **Removed**: assertions on `createSession` calls.
|
|
340
|
+
- Session page pending-prompt test (extend or new):
|
|
341
|
+
- Mock controller; pre-write `sessionStorage["scorel.pending-prompt:<id>"]`; render session page; expect controller.send called once with the pending text; expect sessionStorage entry cleared after send.
|
|
342
|
+
- Re-render same component; expect controller.send NOT called again.
|
|
343
|
+
|
|
344
|
+
**Modified**:
|
|
345
|
+
|
|
346
|
+
- `apps/webui/components/shell/sidebar.test.tsx` — adapt assertions tied to the old NewChatButton API; assert `+ 新对话` is a button that navigates without creating sessions.
|
|
347
|
+
- `apps/webui/lib/sync/session-create.test.ts` (if exists) — no behavior change; helper still used by `EmptyComposer.handleSend`.
|
|
348
|
+
|
|
349
|
+
**Boundary**:
|
|
350
|
+
|
|
351
|
+
- `apps/webui/src/package-boundaries.test.ts` — add a check: `apps/webui/components/shell/new-chat-button.tsx` must not import `lib/sync/session-create` (ensures the rewrite removes the old dependency).
|
|
352
|
+
|
|
353
|
+
### 8. Empty composer placeholder text
|
|
354
|
+
|
|
355
|
+
design.md mandates "Message Scorel…" for chatbox composer; for empty-state we use **"随心输入"** per the screenshot. Pass via `placeholder` prop. Both placeholders use `text-faint`.
|
|
356
|
+
|
|
357
|
+
### 9. Picker styling
|
|
358
|
+
|
|
359
|
+
- `<select>` strips native styling: `appearance-none` if needed (Tailwind 4 doesn't auto-strip). Add a small `<span>▾</span>` next to label OR rely on native chevron.
|
|
360
|
+
- Pickers row width: `mx-auto`, gap 12px, font 14px text-muted.
|
|
361
|
+
- Hover on the `<select>` only (since others are disabled): `hover:text-text`.
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Not In Scope
|
|
366
|
+
|
|
367
|
+
- Project hover `...` menu / ✏ icon button on sidebar project rows.
|
|
368
|
+
- Bottom decorative cards (连接消息传送 / 邮件 / 文件).
|
|
369
|
+
- "完全访问权限" orange badge.
|
|
370
|
+
- Real local/remote mode switching, real branch picker, real model picker — all stay disabled placeholders.
|
|
371
|
+
- Daemon-side firstPrompt (one-step create + send) protocol.
|
|
372
|
+
- Multi-device picker on the empty surface (URL `?device=` + sidebar device click is enough).
|
|
373
|
+
- Auto-connect on landing — existing `useConnection` flow unchanged.
|
|
374
|
+
- Empty composer on `/devices/:id` and `/devices/:id/projects/:slug` pages — only `/` converts in this spec.
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## Acceptance Criteria
|
|
379
|
+
|
|
380
|
+
1. `/` with at least one device renders centered H1 "我们应该在 Scorel 中构建什么?" + pill Composer (placeholder "随心输入") + picker row (project select, mode placeholder, branch placeholder).
|
|
381
|
+
2. `/` with zero devices preserves the existing "先添加设备" + Settings CTA.
|
|
382
|
+
3. Project `<select>` lists current device's projects; `value` resolves URL `?project=` > `routeProjectSlug` > localStorage > first available.
|
|
383
|
+
4. Switching project via `<select>` calls `router.replace` with `?device=&project=` and writes localStorage `scorel.ui.last-active-project`.
|
|
384
|
+
5. `<EmptyComposer>` `handleSend` calls `createSessionForProject`, on success writes `sessionStorage["scorel.pending-prompt:<id>"]`, then `router.push` to session route.
|
|
385
|
+
6. `<EmptyComposer>` `handleSend` failure surfaces an error message via `errorBanner` and does NOT write sessionStorage.
|
|
386
|
+
7. Sidebar `+ 新对话` button (variant `sidebar`) navigates to `/?device=…&project=…` (or `/`), never calls `createSession`.
|
|
387
|
+
8. Project-page `New Chat` button (variant `page`) does the same.
|
|
388
|
+
9. `new-chat-button.tsx` no longer imports `lib/sync/session-create`. Boundary test enforces.
|
|
389
|
+
10. Session page mounts → after `snapshot.loading === false` → reads `sessionStorage["scorel.pending-prompt:<sessionId>"]` → calls `controller.send(pending)` exactly once → removes the storage key.
|
|
390
|
+
11. Mode + branch buttons are `<button disabled className="btn-disabled">`. No hover reaction. `cursor-not-allowed`.
|
|
391
|
+
12. `pnpm --filter @scorel/app-webui typecheck && test && build` green.
|
|
392
|
+
13. Repo `pnpm typecheck && pnpm test` green.
|
|
393
|
+
14. Manual e2e (out of opus scope, log in follow-up verification doc):
|
|
394
|
+
- Land at `/`, type prompt, click send → routes to `/devices/.../sessions/<new>`, user bubble appears, assistant streams.
|
|
395
|
+
- Type prompt, click sidebar `+ 新对话` mid-typing → routes to `/` clearing the in-flight state. **No empty session in daemon `list_sessions`**.
|
|
396
|
+
- Switch project via picker → URL updates, localStorage persists.
|
|
397
|
+
- From session page click sidebar `+ 新对话` → routes to `/?device=&project=` carrying current context.
|
|
398
|
+
- Verify daemon JSONL: a session is only created on the first send.
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## Affected Paths
|
|
403
|
+
|
|
404
|
+
- `apps/webui/app/page.tsx`
|
|
405
|
+
- `apps/webui/components/chatbox/empty-composer.tsx` — **new**
|
|
406
|
+
- `apps/webui/components/chatbox/empty-composer.test.tsx` — **new**
|
|
407
|
+
- `apps/webui/components/shell/new-chat-button.tsx`
|
|
408
|
+
- `apps/webui/components/shell/new-chat-button.test.tsx`
|
|
409
|
+
- `apps/webui/components/shell/sidebar.test.tsx` (adjust)
|
|
410
|
+
- `apps/webui/lib/store/last-active-project.ts` — **new**
|
|
411
|
+
- `apps/webui/lib/store/last-active-project.test.ts` — **new**
|
|
412
|
+
- `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/page.tsx` (pending-prompt consumption)
|
|
413
|
+
- `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/page.test.tsx` (or new pending-prompt test file)
|
|
414
|
+
- `apps/webui/src/package-boundaries.test.ts` (forbid `session-create` import in `new-chat-button.tsx`)
|
|
415
|
+
- `docs/ROADMAP.md` — append M5.9 + S0046 row
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## Risks And Boundaries
|
|
420
|
+
|
|
421
|
+
- **Pending-prompt race**: if `controller.send` is awaited before `start()` resolves the resync, send rejects. Gate strictly on `snapshot.loading === false`.
|
|
422
|
+
- **sessionStorage leak**: if user creates a session but closes the tab before navigation completes, the storage entry remains. Cleanup is best-effort; on a future visit to the session route the entry is consumed; otherwise it idles. Acceptable — sessionStorage clears on tab close anyway.
|
|
423
|
+
- **Half-failed create+send**: if `createSession` succeeds but `router.push` is interrupted (rare), the session exists empty. User can navigate to it manually. Not a new failure mode.
|
|
424
|
+
- **Multiple tabs**: each tab has independent sessionStorage. A `+ 新对话` in one tab doesn't affect another. URL query is the only cross-tab hint, which is fine.
|
|
425
|
+
- **Default project mismatch**: localStorage may point to a project no longer on the device. Fallback to first available avoids a stuck `<select>`.
|
|
426
|
+
- **`<select>` styling**: Native looks slightly off-brand on Safari/Chrome. Acceptable; if user complaints arise, swap to a Base UI Combobox in a follow-up spec.
|
|
427
|
+
- **Keep router state lean**: `router.replace` for picker change avoids polluting browser back history; `router.push` for send is intentional (back navigates back to empty composer).
|
|
428
|
+
- **Single-PR scope**: Touches `/`, NewChatButton, session page, two new lib helpers, two new components. One commit `S0046: feat: empty-state composer + lazy session creation`.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# S0047: WebUI Project Hover New-Chat Button + Dynamic Empty H1
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Two small fixes after S0046 ship:
|
|
6
|
+
|
|
7
|
+
1. **Project hover new-chat button**: each Sidebar Project row exposes a hover-only `✏` button on the right; click navigates to `/?device=&project=` (the empty composer pre-populated with that project). Same shape as Chatbox app's "在 X 中开始新对话" entry.
|
|
8
|
+
2. **Dynamic H1**: `EmptyComposer` H1 must not hardcode "Scorel". Render `我们应该在 {projectDisplayName} 中构建什么?` using the resolved project's `displayName ?? projectSlug`. Fall back to `我们应该构建什么?`(no project name) when no project resolves.
|
|
9
|
+
|
|
10
|
+
Locked by user feedback after `97b338b`. No brainstorm — both fixes are tiny + visually obvious from the screenshot.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Scope
|
|
15
|
+
|
|
16
|
+
### 1. ProjectNode hover button
|
|
17
|
+
|
|
18
|
+
`apps/webui/components/shell/project-node.tsx`:
|
|
19
|
+
|
|
20
|
+
Wrap the existing `<button type="button" onClick={handleClick}>` in a flex container that exposes a sibling `<button>` rendered only on hover/focus. The whole row stays click-toggle; the `✏` button is a separate hit area.
|
|
21
|
+
|
|
22
|
+
Keyboard accessibility: the new button is focusable; tabbing through the sidebar reveals it. Hover behavior uses the parent `group` Tailwind class so the button only paints on row hover.
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
"use client";
|
|
26
|
+
|
|
27
|
+
import { useRouter } from "next/navigation";
|
|
28
|
+
// ... existing imports ...
|
|
29
|
+
|
|
30
|
+
export function ProjectNode({
|
|
31
|
+
deviceId,
|
|
32
|
+
project,
|
|
33
|
+
activeSessionId,
|
|
34
|
+
offline,
|
|
35
|
+
onSelect,
|
|
36
|
+
}: ProjectNodeProps): JSX.Element {
|
|
37
|
+
const router = useRouter();
|
|
38
|
+
// ... existing logic unchanged through `handleClick` ...
|
|
39
|
+
|
|
40
|
+
function handleNewChat(event: React.MouseEvent | React.KeyboardEvent): void {
|
|
41
|
+
event.stopPropagation();
|
|
42
|
+
const params = new URLSearchParams();
|
|
43
|
+
params.set("device", deviceId);
|
|
44
|
+
params.set("project", project.projectSlug);
|
|
45
|
+
router.push(`/?${params.toString()}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<li>
|
|
50
|
+
<div className="group relative flex w-full items-center">
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={handleClick}
|
|
54
|
+
aria-expanded={!collapsed}
|
|
55
|
+
className="flex flex-1 items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-sm text-text hover:bg-surface-hover"
|
|
56
|
+
>
|
|
57
|
+
<span className="truncate">
|
|
58
|
+
{project.displayName ?? project.projectSlug}
|
|
59
|
+
</span>
|
|
60
|
+
{sessionCount !== undefined ? (
|
|
61
|
+
<span className="shrink-0 text-xs text-faint">{sessionCount}</span>
|
|
62
|
+
) : null}
|
|
63
|
+
</button>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={handleNewChat}
|
|
67
|
+
data-testid={`project-new-chat-${project.projectSlug}`}
|
|
68
|
+
aria-label={`在 ${project.displayName ?? project.projectSlug} 中开始新对话`}
|
|
69
|
+
title={`在 ${project.displayName ?? project.projectSlug} 中开始新对话`}
|
|
70
|
+
className="ml-1 hidden h-6 w-6 shrink-0 items-center justify-center rounded-sm text-muted hover:bg-surface-hover hover:text-text group-hover:flex focus-visible:flex"
|
|
71
|
+
>
|
|
72
|
+
<span aria-hidden>✏</span>
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
{/* ... existing collapse/sessions list unchanged ... */}
|
|
76
|
+
</li>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Notes:
|
|
82
|
+
|
|
83
|
+
- `group-hover:flex` toggles visibility on parent hover; `focus-visible:flex` on the button itself ensures keyboard navigation reveals it.
|
|
84
|
+
- `event.stopPropagation()` prevents the row's `handleClick` from firing the toggle when the user clicks the `✏` button.
|
|
85
|
+
- Native `disabled` not used — this button always works as long as the device is configured. (Offline state could disable it later; out of scope.)
|
|
86
|
+
|
|
87
|
+
### 2. EmptyComposer dynamic H1
|
|
88
|
+
|
|
89
|
+
`apps/webui/components/chatbox/empty-composer.tsx`:
|
|
90
|
+
|
|
91
|
+
Compute `projectLabel`:
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
const project = projects.find((p) => p.projectSlug === projectSlug);
|
|
95
|
+
const projectLabel = project?.displayName ?? project?.projectSlug;
|
|
96
|
+
|
|
97
|
+
const greetingText = projectLabel
|
|
98
|
+
? `我们应该在 ${projectLabel} 中构建什么?`
|
|
99
|
+
: `我们应该构建什么?`;
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Render:
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
<h1 className="greeting text-center" data-testid="empty-composer-greeting">
|
|
106
|
+
{greetingText}
|
|
107
|
+
</h1>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Zero-devices branch keeps "欢迎使用 Scorel" since that's a brand greeting (not a project name) — acceptable hardcode.
|
|
111
|
+
|
|
112
|
+
### 3. Tests
|
|
113
|
+
|
|
114
|
+
**Modified — `project-node.test.tsx`**:
|
|
115
|
+
|
|
116
|
+
- New case: render with hovered row → `✏` button appears (Tailwind hover state hard to assert in jsdom; assert it's in the DOM with the `hidden` class and that click triggers `router.push`). Use `data-testid="project-new-chat-<slug>"` for selection.
|
|
117
|
+
- Click `✏` button → `router.push("/?device=…&project=…")` called once. Use `vi.mock` for `useRouter` like other sidebar tests.
|
|
118
|
+
- Click `✏` does NOT fire toggle: existing `handleClick` not invoked. Verify by checking `useCollapsed` state unchanged after click.
|
|
119
|
+
|
|
120
|
+
**Modified — `empty-composer.test.tsx`**:
|
|
121
|
+
|
|
122
|
+
- When projects array contains a project with `displayName: "Scorel"`, H1 reads "我们应该在 Scorel 中构建什么?".
|
|
123
|
+
- When projects exist but no `displayName`, H1 uses `projectSlug`.
|
|
124
|
+
- When projects array empty (but devices exist), H1 reads "我们应该构建什么?".
|
|
125
|
+
- Zero-devices branch unchanged ("欢迎使用 Scorel").
|
|
126
|
+
|
|
127
|
+
### 4. Boundary
|
|
128
|
+
|
|
129
|
+
No new dependencies. No daemon changes. Files touched: 2 source + 2 test.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Not In Scope
|
|
134
|
+
|
|
135
|
+
- Project row `...` overflow menu (rename / delete / settings).
|
|
136
|
+
- Disabling `✏` when device offline.
|
|
137
|
+
- Editable session title in chatbox.
|
|
138
|
+
- ROADMAP milestone update — append S0047 spec row only.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Acceptance Criteria
|
|
143
|
+
|
|
144
|
+
1. ProjectNode renders a hidden `✏` button per row; CSS class set so it only appears on `group-hover` or `focus-visible`.
|
|
145
|
+
2. Click `✏` calls `router.push("/?device=<deviceId>&project=<slug>")`.
|
|
146
|
+
3. Click `✏` does NOT toggle the project's collapsed state. Existing row-click toggle behavior preserved.
|
|
147
|
+
4. `aria-label` and `title` use `project.displayName ?? project.projectSlug` (no hardcoded brand).
|
|
148
|
+
5. EmptyComposer H1 reads `我们应该在 {displayName ?? slug} 中构建什么?` when a project resolves.
|
|
149
|
+
6. EmptyComposer H1 falls back to `我们应该构建什么?` when no project resolves (devices exist but projects empty).
|
|
150
|
+
7. Zero-devices branch unchanged.
|
|
151
|
+
8. `pnpm --filter @scorel/app-webui typecheck && test && build` green.
|
|
152
|
+
9. Repo `pnpm typecheck && pnpm test` green.
|
|
153
|
+
10. Manual e2e:
|
|
154
|
+
- Hover any project row → `✏` appears on right.
|
|
155
|
+
- Click `✏` → routes to `/?device=&project=` with that row's context; H1 shows that project's name.
|
|
156
|
+
- Sidebar `+ 新对话` from a session route still works (S0046 path).
|
|
157
|
+
- On `/?project=Scorel` H1 reads "我们应该在 Scorel 中构建什么?".
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Affected Paths
|
|
162
|
+
|
|
163
|
+
- `apps/webui/components/shell/project-node.tsx`
|
|
164
|
+
- `apps/webui/components/shell/project-node.test.tsx`
|
|
165
|
+
- `apps/webui/components/chatbox/empty-composer.tsx`
|
|
166
|
+
- `apps/webui/components/chatbox/empty-composer.test.tsx`
|
|
167
|
+
- `docs/ROADMAP.md` — append S0047 spec row only
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Risks And Boundaries
|
|
172
|
+
|
|
173
|
+
- **`group-hover` reliability in jsdom**: jsdom doesn't apply real CSS hover, so the test asserts the button exists in the DOM with `data-testid` + that click fires the right router call. Visual hover behavior is verified manually.
|
|
174
|
+
- **Stop-propagation correctness**: missing `event.stopPropagation()` would toggle collapse on `✏` click. The test asserts the toggle didn't fire.
|
|
175
|
+
- **Project label fallback chain**: `displayName ?? projectSlug` — both could be empty strings (rare, defensive). If both empty, H1 reads "我们应该在 中构建什么?" — accept; daemon-side validation should keep slug non-empty.
|
|
176
|
+
- **Single commit**: `S0047: feat: project hover new-chat + dynamic empty greeting`.
|