Skip to content

Commit b52319a

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: - 输入历史/草稿 (was ❌): new composer-history projection (record / browse / persist pure functions, 19 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 via a pruned localStorage store, and the home slot migrates into the silently created first session. - 斜杠命令面板 (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, @ menu rows, picker drives the OS chooser through Playwright's filechooser). Also fix the Windows tailwind spawn (POSIX .bin shim is unspawnable there; invoke the JS entry with the running Node executable). Tests: webui unit suite 877 passed / 14 failed — the failing 14 are the same tests that fail on clean 83321d0 in this Windows environment (boundary-check path/symlink handling, css-structure, WS timing; all pre-existing, none touched by this change). Browser suite 30/30 after `pnpm build:webui`.
1 parent 83321d0 commit b52319a

9 files changed

Lines changed: 962 additions & 4 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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 per draft value; the
51+
persisted blob is pruned to the newest slots when over budget. Storage
52+
failures (privacy mode, quota) degrade silently to in-memory state — same
53+
convention as `no-project.ts`.
54+
55+
### Units (pure, unit-tested before wiring)
56+
57+
New module `packages/webui/src/client/projection/composer-history.ts`:
58+
59+
- `recordWebuiInputHistory(history, input, options?) → string[]`
60+
- `startWebuiHistoryBrowse(history, currentDraft) → browse | undefined`
61+
- `stepWebuiHistoryBrowse(browse, direction, history) → browse | undefined`
62+
(`undefined` = exit and restore the stashed draft)
63+
- `shouldRecallWebuiHistory(caret, value) → boolean` (first-line rule)
64+
- `loadWebuiComposerPersisted(storage?) / saveWebuiComposerPersisted(...)`
65+
with sanitize + prune
66+
67+
Wiring: the shell owns the persisted store (drafts + history) keyed by
68+
`selectedSessionId ?? "home"`; `SessionComposer` receives `history`,
69+
`onInputSubmitted`, and the per-session `draft`/`onDraftChange` it has today.
70+
71+
## B-2 斜杠命令面板 (dev `/` trigger)
72+
73+
`slash-palette.ts`, `composer-interactions.ts` (`findWebuiSlashRange`) and the
74+
`SessionComposer` popover wiring all read complete at `83321d0`; the roadmap's
75+
"dev 输入 `/` 未触发" therefore needs reproduction before any fix is assumed.
76+
Plan: a browser-harness spec types `/` into the built client's composer and
77+
asserts the palette rows render (goal / plan / deploy-website / skills). If it
78+
reproduces, fix the root cause (suspects: module-level `await` fallback path,
79+
`slashSkills` initial state, popover positioning) and re-run; if it does not
80+
reproduce on the built client, verify once on `pnpm dev` and record the dev
81+
observation as the evidence line, closing the row as a stale report.
82+
83+
## B-3 上传预览 / B-4 文件夹上传与 @ 引用
84+
85+
Both rows are "链路验收通过, 未逐项实测". Closure = browser-harness specs driving
86+
the built client: attach a file through the hidden input, assert the attachment
87+
chip (name + size) and its removal; add a folder via the directory input and
88+
assert the folder chip; type `@` and assert the mention menu offers 添加本地文件
89+
/ 添加本地文件夹 rows and that choosing one opens the corresponding picker.
90+
These ride the existing fixture transport (attachments are client-side state
91+
until submit; the fixture accepts the submit envelope).
92+
93+
## Verification gates (all must pass before reporting)
94+
95+
1. `pnpm --filter @mavis/webui test` — full webui unit suite green, including
96+
the new `composer-history` tests.
97+
2. `node scripts/run-vitest-suite.mjs` suites that exist for webui stay green
98+
(no regressions in the shared suites).
99+
3. `pnpm build:webui` + `pnpm test:webui-browser` — existing three specs plus
100+
the new composer spec green.
101+
4. One manual `pnpm dev` pass for the slash palette observation (B-2) and a
102+
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: 70 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+
recordWebuiInputHistory,
25+
saveWebuiComposerPersisted,
26+
WEBUI_COMPOSER_HOME_KEY,
27+
WEBUI_INPUT_HISTORY_LIMIT,
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,43 @@ 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+
const setDraft = useCallback((next: string) => {
282+
setComposerStore((current) => {
283+
const drafts = { ...current.drafts };
284+
if (next) drafts[composerKeyRef.current] = next;
285+
else delete drafts[composerKeyRef.current];
286+
const state = { ...current, drafts };
287+
saveWebuiComposerPersisted(state);
288+
return state;
289+
});
290+
}, []);
291+
// Recording rides the same store: a committed submission lands in the
292+
// current slot's history (home while no session exists yet; the created
293+
// session inherits the entry below in `handleSessionCreated`).
294+
const recordComposerInput = useCallback((text: string) => {
295+
setComposerStore((current) => {
296+
const key = composerKeyRef.current;
297+
const history = {
298+
...current.history,
299+
[key]: recordWebuiInputHistory(current.history[key] ?? [], text),
300+
};
301+
const state = { ...current, history };
302+
saveWebuiComposerPersisted(state);
303+
return state;
304+
});
305+
}, []);
262306
const [teamModeOff, setTeamModeOff] = useState(readTeamModeOff);
263307
const [teamModeChoices, setTeamModeChoices] =
264308
useState<TeamModeSessionChoices>(readTeamModeSessionChoices);
@@ -615,6 +659,29 @@ export function WebuiClientFoundationApp(
615659
// carry it (and the sending flag) across the view switch so the reply
616660
// stays on screen, and leave home clean.
617661
migrateSessionRuntimeState(HOME_SESSION_RUNTIME_KEY, id);
662+
// The composer store follows the same home → session migration: the
663+
// input just submitted was recorded under the home slot, and the new
664+
// session's history (and any unsent draft) should own it from here on.
665+
setComposerStore((current) => {
666+
const homeHistory = current.history[WEBUI_COMPOSER_HOME_KEY];
667+
const homeDraft = current.drafts[WEBUI_COMPOSER_HOME_KEY];
668+
if (!homeHistory && homeDraft === undefined) return current;
669+
const history = { ...current.history };
670+
const drafts = { ...current.drafts };
671+
if (homeHistory) {
672+
history[id] = [...(history[id] ?? []), ...homeHistory].slice(
673+
-WEBUI_INPUT_HISTORY_LIMIT,
674+
);
675+
delete history[WEBUI_COMPOSER_HOME_KEY];
676+
}
677+
if (homeDraft !== undefined) {
678+
drafts[id] = homeDraft;
679+
delete drafts[WEBUI_COMPOSER_HOME_KEY];
680+
}
681+
const state = { ...current, drafts, history };
682+
saveWebuiComposerPersisted(state);
683+
return state;
684+
});
618685
setSelectedSessionId(id);
619686
writeTeamModeSessionChoice(id, teamModeOff);
620687
setTeamModeChoices((current) => ({ ...current, [id]: teamModeOff }));
@@ -987,6 +1054,8 @@ export function WebuiClientFoundationApp(
9871054
getAccountStatus={transport?.getAccountStatus}
9881055
draft={draft}
9891056
onDraftChange={setDraft}
1057+
inputHistory={composerStore.history[composerKey] ?? []}
1058+
onInputSubmitted={recordComposerInput}
9901059
teamModeOff={composerTeamModeOff}
9911060
sessions={page.sessions}
9921061
workspaceDir={selectedSession?.workspaceDir ?? newTaskWorkspaceDir}

0 commit comments

Comments
 (0)