Skip to content

Commit 3b97079

Browse files
committed
feat(webui): composer input history, per-session drafts, and dev-mount fix (Module B)
Close the four remaining roadmap Module B (Composer 输入区) rows on top of webui 83321d0. Spec first: docs/webui/webui-composer-module-b-spec.md. - 输入历史/草稿 (was ❌): composer-history projection (record / browse / migrate / persist pure functions, 22 unit tests) wired into the shell and the session composer. ↑ recalls committed inputs (clamped oldest, exit past newest restores the stashed draft, manual edit exits, first-line caret rule), drafts are per session and survive switches and reloads through a pruned localStorage store, and the home slot migrates into the silently created first session. The prune caps stored strings at 64 KiB of UTF-8 bytes (not characters) and pins the live session's slot through keepKeys so it can never evict itself. - 斜杠命令面板 (was 🟡): the dev failure was never in the palette — the vite dev middleware answered `?import` module requests (e.g. the lottie JSON import) with raw bytes and no Content-Type, so the strict module MIME check failed the import and the whole dev page never mounted. Query-carrying requests now fall through to Vite's transform pipeline and the middleware sets Content-Type for the files it serves. Verified on the vite dev server and on the built client. - 上传预览 and 文件夹上传/@引用 (were ➖): verified end to end by the new composer browser spec (chips, image thumbnail, removal, directory-input folder selection, @ menu rows, picker drives the OS chooser through Playwright's filechooser). Tests: webui unit suite 880 passed / 14 failed — the failing 14 are the same tests that fail on clean 83321d0 in this Windows environment (all pre-existing; the CI-visible baseline reds are tracked separately in #2 and #6). Browser suite 31/31 after `pnpm build:webui`.
1 parent 6379dcf commit 3b97079

8 files changed

Lines changed: 1096 additions & 2 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# WebUI Composer — Module B closure spec (roadmap 2026-10-03)
2+
3+
Baseline: `webui` branch HEAD `83321d0`. Scope: the four remaining Module B
4+
(Composer 输入区) rows from the team roadmap (`mcode-webui-roadmap`), following
5+
the same workflow the Module C closure used: spec first, TDD, browser-harness
6+
end-to-end run, then report with evidence.
7+
8+
| Roadmap row | Today | Target |
9+
|---|---|---|
10+
| 输入历史/草稿 (codex ↑ recall; cross-session drafts) | ❌ | ✅ |
11+
| 斜杠命令面板 (dev `/` does not open) | 🟡 | ✅ (verify or fix) |
12+
| 文件/图片/多文件上传与预览 | ➖ | ✅ (verified) |
13+
| 文件夹上传 / @ 引用 | ➖ | ✅ (verified) |
14+
15+
## B-1 输入历史/草稿
16+
17+
Codex parity: the composer recalls previously submitted inputs with ↑/↓, and
18+
an unsent draft survives both session switches and a page reload.
19+
20+
### Behavior
21+
22+
1. **Recording.** Every committed composer submission — `submit-turn` (send or
23+
queue), `submit-goal`, and `run-command` — records the submitted text in a
24+
per-session input history. Mode activations (`/goal`, `/plan` bare) record
25+
nothing. Recording happens at commit time (after intent resolution), not on
26+
transport success: a refused send must not silently eat the user's input
27+
history. The home composer (no session yet) records into the `home` slot;
28+
when the silent first-session creation commits, the entry moves to the
29+
created session's history.
30+
2. **Normalization.** Entries are trimmed; blank entries are not recorded; an
31+
entry identical to the current newest entry collapses (no consecutive
32+
duplicates); history is capped at 100 entries per session (oldest dropped).
33+
3. **Recall.** With the caret on the first line (selectionStart 0 or within
34+
the first line) and no slash popover / mention menu open:
35+
- ↑ steps to the previous (older) entry and replaces the draft;
36+
- ↓ steps forward; stepping past the newest restores the pre-browse draft
37+
and exits browse mode;
38+
- Escape during browse exits and restores the pre-browse draft;
39+
- any manual edit exits browse mode;
40+
- ↑ on a non-first-line caret keeps the caret's default behavior (the
41+
textarea's own intra-draft navigation must not be hijacked).
42+
4. **Cross-session drafts.** Drafts are per session (home has its own slot).
43+
Switching sessions saves the outgoing draft and restores the incoming one.
44+
Drafts persist to `localStorage` and survive a reload. `新建任务` keeps its
45+
existing clearing semantics. The home → silently-created-session draft
46+
carry-over (existing behavior, see `SessionComposer` props comment) is
47+
preserved.
48+
5. **Persistence shape.** One key, `webui.composer.state.v1`, holding
49+
`{ drafts: Record<sessionKey, string>, history: Record<sessionKey, string[]> }`.
50+
Limits: 50 draft slots / 100 history slots / 64 KiB (UTF-8 bytes, not
51+
characters) per stored value; the persisted blob is pruned to the newest
52+
slots when over budget, with the live session's slot pinned so the prune
53+
can never evict it. Storage failures (privacy mode, quota) degrade
54+
silently to in-memory state — same convention as `no-project.ts`.
55+
56+
### Units (pure, unit-tested before wiring)
57+
58+
New module `packages/webui/src/client/projection/composer-history.ts`:
59+
60+
- `recordWebuiInputHistory(history, input) → string[]`
61+
- `startWebuiHistoryBrowse(history, currentDraft) → browse | undefined`
62+
- `stepWebuiHistoryBrowse(browse, direction, history) → browse | undefined`
63+
(`undefined` = exit and restore the stashed draft)
64+
- `shouldRecallWebuiHistory(value, caret) → boolean` (first-line rule)
65+
- `loadWebuiComposerPersisted(storage?) / saveWebuiComposerPersisted(state,
66+
storage?, keepKeys?)` with sanitize + prune; `keepKeys` pins the live
67+
session's slot so an insertion-order prune can never evict it
68+
- `migrateWebuiHomeComposerState(state, targetKey)` — home slot → created
69+
session key (recorded under home before the session existed)
70+
71+
Wiring: the shell owns the persisted store (drafts + history) keyed by
72+
`selectedSessionId ?? "home"`; `SessionComposer` receives `history`,
73+
`onInputSubmitted`, and the per-session `draft`/`onDraftChange` it has today.
74+
75+
## B-2 斜杠命令面板 (dev `/` trigger)
76+
77+
`slash-palette.ts`, `composer-interactions.ts` (`findWebuiSlashRange`) and the
78+
`SessionComposer` popover wiring all read complete at `83321d0`; the roadmap's
79+
"dev 输入 `/` 未触发" therefore needs reproduction before any fix is assumed.
80+
Plan: a browser-harness spec types `/` into the built client's composer and
81+
asserts the palette rows render (goal / plan / deploy-website / skills). If it
82+
reproduces, fix the root cause (suspects: module-level `await` fallback path,
83+
`slashSkills` initial state, popover positioning) and re-run; if it does not
84+
reproduce on the built client, verify once on `pnpm dev` and record the dev
85+
observation as the evidence line, closing the row as a stale report.
86+
87+
## B-3 上传预览 / B-4 文件夹上传与 @ 引用
88+
89+
Both rows are "链路验收通过, 未逐项实测". Closure = browser-harness specs driving
90+
the built client: attach a file through the hidden input, assert the attachment
91+
chip (name + size) and its removal; add a folder via the directory input and
92+
assert the folder chip; type `@` and assert the mention menu offers 添加本地文件
93+
/ 添加本地文件夹 rows and that choosing one opens the corresponding picker.
94+
These ride the existing fixture transport (attachments are client-side state
95+
until submit; the fixture accepts the submit envelope).
96+
97+
## Verification gates (all must pass before reporting)
98+
99+
1. `pnpm --filter @mavis/webui test` — full webui unit suite green, including
100+
the new `composer-history` tests.
101+
2. `node scripts/run-vitest-suite.mjs` suites that exist for webui stay green
102+
(no regressions in the shared suites).
103+
3. `pnpm build:webui` + `pnpm test:webui-browser` — existing three specs plus
104+
the new composer spec green.
105+
4. One manual `pnpm dev` pass for the slash palette observation (B-2) and a
106+
reload/switch pass for drafts + ↑ recall.

‎packages/webui/src/client/components/SessionComposer.tsx‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@ import {
139139
webuiAttachmentLimitError,
140140
type WebuiMentionRange,
141141
} from "../projection/composer-interactions.js";
142+
import {
143+
shouldRecallWebuiHistory,
144+
startWebuiHistoryBrowse,
145+
stepWebuiHistoryBrowse,
146+
type WebuiHistoryBrowse,
147+
} from "../projection/composer-history.js";
142148
import {
143149
isWebuiRunnableCommand,
144150
rankWebuiSlashPalette,
@@ -506,6 +512,8 @@ export function WebuiComposer({
506512
getAccountStatus,
507513
draft,
508514
onDraftChange,
515+
inputHistory = [],
516+
onInputSubmitted,
509517
onNeedsSession,
510518
onSessionCreated,
511519
enqueueMessage,
@@ -564,6 +572,14 @@ export function WebuiComposer({
564572
/** The draft lives on the shell so it survives silent first-session creation. */
565573
readonly draft: string;
566574
readonly onDraftChange: (next: string) => void;
575+
/**
576+
* Submitted-input history for ↑ recall (roadmap Module B: 输入历史/草稿).
577+
* Keyed by session on the shell; the composer only walks it with the pure
578+
* helpers from `composer-history.ts`.
579+
*/
580+
readonly inputHistory?: readonly string[];
581+
/** Records one committed submission into the shell's history store. */
582+
readonly onInputSubmitted?: (text: string) => void;
567583
readonly onNeedsSession?: (draft: string) => void;
568584
readonly onSessionCreated?: (sessionId: string) => void;
569585
readonly teamModeOff: boolean;
@@ -636,6 +652,27 @@ export function WebuiComposer({
636652
// ref: a click or an arrow key that only moves the caret still has to
637653
// re-derive whether a slash token is under it.
638654
const [composerCaret, setComposerCaret] = useState(0);
655+
// ↑ recall browse state (roadmap Module B). `undefined` = not browsing.
656+
// While set, ↑/↓ walk `inputHistory` and any manual edit exits (the
657+
// textarea's onChange only fires for real user edits — a programmatic
658+
// recall swap never ends its own browse); stepping past the newest
659+
// restores the stashed pre-browse draft.
660+
const [historyBrowse, setHistoryBrowse] = useState<WebuiHistoryBrowse>();
661+
useEffect(() => {
662+
// Switching sessions swaps the history list under the browse cursor;
663+
// restart clean rather than trusting the index still means anything.
664+
setHistoryBrowse(undefined);
665+
}, [sessionId]);
666+
const applyHistoryDraft = (next: string) => {
667+
handleDraftChange(next);
668+
setComposerCaret(next.length);
669+
// The DOM caret lags the controlled value swap by a render; pin it to
670+
// the end once the new value is on screen, the way a terminal leaves the
671+
// cursor after a recall.
672+
requestAnimationFrame(() => {
673+
textareaRef.current?.setSelectionRange(next.length, next.length);
674+
});
675+
};
639676
const editingGoalDraftRef = useRef(false);
640677
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
641678
const composerRegionRef = useRef<HTMLDivElement | null>(null);
@@ -1636,6 +1673,19 @@ export function WebuiComposer({
16361673
});
16371674
const resolvedIntent = intent ?? (attachments.length > 0 ? { kind: "submit-turn" as const } : undefined);
16381675
if (!resolvedIntent) return;
1676+
// Record the sendable text for ↑ recall on every committed intent —
1677+
// send/queue turns, goal objectives, and runnable commands all start as
1678+
// user-typed composer text. Recording at commit time (not transport
1679+
// success) keeps a refused send from silently eating the user's input,
1680+
// and mode activations (`/goal`, `/plan` bare) record nothing.
1681+
if (
1682+
resolvedIntent.kind === "submit-turn" ||
1683+
resolvedIntent.kind === "submit-goal" ||
1684+
resolvedIntent.kind === "run-command"
1685+
) {
1686+
onInputSubmitted?.(messageDraft);
1687+
setHistoryBrowse(undefined);
1688+
}
16391689
if (resolvedIntent.kind === "activate-goal-mode") {
16401690
activateGoalMode();
16411691
return;
@@ -1898,6 +1948,9 @@ export function WebuiComposer({
18981948
onChange={(event) => {
18991949
const next = event.target.value;
19001950
handleDraftChange(next);
1951+
// A real user edit ends any active history browse (see
1952+
// the browse state comment above).
1953+
setHistoryBrowse(undefined);
19011954
const caret = event.target.selectionStart;
19021955
mentionCaretRef.current = caret;
19031956
setComposerCaret(caret);
@@ -1961,6 +2014,48 @@ export function WebuiComposer({
19612014
setComposerCaret(next.length);
19622015
return;
19632016
}
2017+
if (event.key === "Escape" && historyBrowse) {
2018+
// Escape during a history browse restores the draft as
2019+
// it was before ↑ first recalled. Sits after the slash
2020+
// palette's own Escape so a recalled "/…" closes the
2021+
// palette first and exits the browse on the second tap.
2022+
event.preventDefault();
2023+
const stashed = historyBrowse.draft;
2024+
setHistoryBrowse(undefined);
2025+
applyHistoryDraft(stashed);
2026+
return;
2027+
}
2028+
// History recall claims ↑/↓ only when nothing else has:
2029+
// the mention menu (above) and the slash popover (below,
2030+
// behind the `commandSuggestions.length === 0` guard) own
2031+
// the arrows while they are open.
2032+
if (
2033+
commandSuggestions.length === 0 &&
2034+
(event.key === "ArrowUp" || event.key === "ArrowDown")
2035+
) {
2036+
const caret = event.currentTarget.selectionStart ?? 0;
2037+
if (event.key === "ArrowUp") {
2038+
const next = historyBrowse
2039+
? stepWebuiHistoryBrowse(historyBrowse, "prev", inputHistory)
2040+
: shouldRecallWebuiHistory(draft, caret)
2041+
? startWebuiHistoryBrowse(inputHistory, draft)
2042+
: undefined;
2043+
if (next) {
2044+
event.preventDefault();
2045+
setHistoryBrowse(next);
2046+
applyHistoryDraft(inputHistory[next.index] ?? "");
2047+
return;
2048+
}
2049+
} else if (historyBrowse) {
2050+
const next = stepWebuiHistoryBrowse(historyBrowse, "next", inputHistory);
2051+
event.preventDefault();
2052+
setHistoryBrowse(next);
2053+
// Stepping past the newest exits and restores the
2054+
// stashed pre-browse draft.
2055+
applyHistoryDraft(next ? inputHistory[next.index] ?? "" : historyBrowse.draft);
2056+
return;
2057+
}
2058+
}
19642059
// Enter is claimed above by an open mention menu, and below by
19652060
// an open slash popover. This MUST sit before the
19662061
// `commandSuggestions.length === 0` guard: that guard

‎packages/webui/src/client/components/WebuiClientFoundationApp.tsx‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ import {
1919
type ReactElement,
2020
} from "react";
2121
import { ArchonShell } from "./ArchonShell.js";
22+
import {
23+
loadWebuiComposerPersisted,
24+
migrateWebuiHomeComposerState,
25+
recordWebuiInputHistory,
26+
saveWebuiComposerPersisted,
27+
WEBUI_COMPOSER_HOME_KEY,
28+
type WebuiComposerPersisted,
29+
} from "../projection/composer-history.js";
2230
import { Composer } from "./Composer.js";
2331
import { GreetingSkeleton } from "./TranscriptSkeletons.js";
2432
import {
@@ -258,7 +266,63 @@ export function WebuiClientFoundationApp(
258266
useEffect(() => {
259267
dispatchShellSurface({ type: "show-conversation" });
260268
}, [dispatchShellSurface, selectedSessionId]);
261-
const [draft, setDraft] = useState("");
269+
// Composer input history + per-session drafts (roadmap Module B:
270+
// 输入历史/草稿). One persisted store keyed by session (home has its own
271+
// slot), so a draft survives both a session switch and a reload, and ↑ in
272+
// the composer recalls that session's submitted inputs. The store is
273+
// best-effort: `localStorage` unavailability degrades to memory.
274+
const [composerStore, setComposerStore] = useState<WebuiComposerPersisted>(
275+
() => loadWebuiComposerPersisted(),
276+
);
277+
const composerKey = selectedSessionId ?? WEBUI_COMPOSER_HOME_KEY;
278+
const composerKeyRef = useRef(composerKey);
279+
composerKeyRef.current = composerKey;
280+
const draft = composerStore.drafts[composerKey] ?? "";
281+
// One updater for every composer-store write: apply the change, persist,
282+
// and keep the named slot alive through the prune. `keepKeys` matters
283+
// because the store's prune keeps the newest-inserted slots, and an
284+
// active session's slot does not re-insert merely by being written to.
285+
const applyComposerStore = useCallback(
286+
(
287+
update: (current: WebuiComposerPersisted) => WebuiComposerPersisted,
288+
keepKey: string,
289+
) => {
290+
setComposerStore((current) => {
291+
const state = update(current);
292+
if (state === current) return current;
293+
saveWebuiComposerPersisted(state, undefined, [keepKey]);
294+
return state;
295+
});
296+
},
297+
[],
298+
);
299+
const setDraft = useCallback(
300+
(next: string) => {
301+
applyComposerStore((current) => {
302+
const drafts = { ...current.drafts };
303+
if (next) drafts[composerKeyRef.current] = next;
304+
else delete drafts[composerKeyRef.current];
305+
return { ...current, drafts };
306+
}, composerKeyRef.current);
307+
},
308+
[applyComposerStore],
309+
);
310+
// Recording rides the same store: a committed submission lands in the
311+
// current slot's history (home while no session exists yet; the created
312+
// session inherits the entry below in `handleSessionCreated`).
313+
const recordComposerInput = useCallback(
314+
(text: string) => {
315+
applyComposerStore((current) => {
316+
const key = composerKeyRef.current;
317+
const history = {
318+
...current.history,
319+
[key]: recordWebuiInputHistory(current.history[key] ?? [], text),
320+
};
321+
return { ...current, history };
322+
}, composerKeyRef.current);
323+
},
324+
[applyComposerStore],
325+
);
262326
const [teamModeOff, setTeamModeOff] = useState(readTeamModeOff);
263327
const [teamModeChoices, setTeamModeChoices] =
264328
useState<TeamModeSessionChoices>(readTeamModeSessionChoices);
@@ -615,6 +679,15 @@ export function WebuiClientFoundationApp(
615679
// carry it (and the sending flag) across the view switch so the reply
616680
// stays on screen, and leave home clean.
617681
migrateSessionRuntimeState(HOME_SESSION_RUNTIME_KEY, id);
682+
// The composer store follows the same home → session migration: the
683+
// input just submitted was recorded under the home slot, and the new
684+
// session's history (and any unsent draft) should own it from here on.
685+
// `keepKey` is the NEW session id — `composerKeyRef` still reads "home"
686+
// until the next render, and home is being deleted anyway.
687+
applyComposerStore(
688+
(current) => migrateWebuiHomeComposerState(current, id),
689+
id,
690+
);
618691
setSelectedSessionId(id);
619692
writeTeamModeSessionChoice(id, teamModeOff);
620693
setTeamModeChoices((current) => ({ ...current, [id]: teamModeOff }));
@@ -987,6 +1060,8 @@ export function WebuiClientFoundationApp(
9871060
getAccountStatus={transport?.getAccountStatus}
9881061
draft={draft}
9891062
onDraftChange={setDraft}
1063+
inputHistory={composerStore.history[composerKey] ?? []}
1064+
onInputSubmitted={recordComposerInput}
9901065
teamModeOff={composerTeamModeOff}
9911066
sessions={page.sessions}
9921067
workspaceDir={selectedSession?.workspaceDir ?? newTaskWorkspaceDir}

0 commit comments

Comments
 (0)