Skip to content

Commit aedab1d

Browse files
committed
feat(webui): a favourites tab beside the rail's three views
The rail had three views -- projects, running, unread -- and no way to keep a session. Pinning answers "put this at the top", which is an ordering preference; it does not answer "I will come back to this one", which is a different question and outlives a re-sort. Starring is that answer, and it was already declared: `WEBUI_SESSION_OVERLAY_KEYS.stars` existed in `LeftRail.tsx` with nothing reading or writing it. Design compliance ----------------- The star deliberately does NOT follow the pin's convention, and that is the point rather than an oversight. The pin's two states are a pin and a slashed pin because a slash means "remove" across this icon vocabulary. Nobody reads a filled star as "un-star" -- Gmail, Mail and Notion all use outline/filled. Reusing the pin's convention here would have made the rail internally consistent and externally wrong, and a rail that is consistently wrong about its most-used affordance teaches the reader to distrust every glyph in it. The pin's button draws the ACTION (already pinned means the click unpins, so it offers the slashed pin). The star's button draws the STATE. One row, two conventions, each correct for its own glyph. The one thing the two share is that both states are drawn by one component, shared between the row button and the context menu, so they cannot drift. Counts come from the page, not from the storage map. A star left on a session that is archived, or that this page has not paged in, is a stale key in localStorage rather than a row; counting the map would put a number on a tab whose body is the empty state. The favourites view is a filter, not a fifth list. Switching to projects and back returns the full rail rather than a list that consumed the other view. `starred` is a required argument of both `filterWebuiRailViewSessions` and `selectWebuiRailViewTabs`, not an optional one. An optional argument typechecks at every call site that forgot to pass it and reports a count of zero -- the same shape of bug as a prop that is accepted and then ignored. Only one of the four views reads it; that is not a reason to make it optional. Both views reach a standing mark. The row's star button only exists while the row is hovered, so without a mark a favourite is invisible everywhere except the tab the reader has to go and open. Pinning already needed this for the same reason and the same fix. Colour: the star is the one mark allowed to shout, in the project's own yellow token (`--text_status_banana`, flips with the theme). The pin beside it stays grey -- a pin is an ordering preference, a favourite is a thing the reader singled out, and they are not the same kind of claim. The filled glyph has to survive the button's own `:hover`, which meant re-asserting the token at (0,3,0) rather than (0,2,0); see the stylesheet comment for why the obvious version silently greys out under the pointer. Validation ---------- `packages/webui/test/unit/rail-star-favorites.test.ts` (29 new cases) and the `rail-buckets` suite retargeted to the new 4-argument signature. `packages/webui/test/unit/webui-shell.test.ts` -- "never leaves a control operable but unbound" moves 11 -> 12 for the fourth tab, and its per-view loop now names `stars` so a view added to the rail without being added there would fail rather than sail past the contract. Both halves of that were checked separately: disabling the tab trips the count, and blanking the count so it cannot trip trips the per-view loop. `test/webui-browser/rail-star.spec.mjs` (18 new cases) covers what static markup cannot: that the mark is visible without a hover, that the paint is actually the yellow token and not the meta's inherited grey -- including with the pointer resting on the button itself -- that the two glyph states are two different drawings rather than one drawing relabelled, that the tab actually filters, and that a starred child row lights up when its parent is expanded, the one row shape a node-environment `renderToStaticMarkup` cannot reach because `childrenExpanded` is internal `useState` with no prop to inject. Test evidence ------------- Unit: 1335 passed, 9 failed -- all 9 pre-existing on Windows (`webui-boundary-check` 7 path assertions, `webui-design-tokens` 1, `webui-service` 1 shutdown timeout). Baseline, unrelated to this change. Browser: 18/18 in this spec; 64/64 across the browser suite. Gates: `typecheck`, `typecheck:webui`, `check:source` (4679 files) all clean. Negative injection, because a suite that has only ever been green proves nothing about what it guards: * unit: 21/21 mutations caught, zero survivors. * browser: 11/11 mutations caught, zero survivors. Every mutation was reverted and the tree rebuilt afterwards, so the final build is the unmutated one. The one bug the suites did not find, and the reason the dev-app look was not skipped: the yellow assertion hovered the row, which never triggers the button's own `:hover`, so it passed over the top of a rule that repainted the glyph the moment the pointer reached the control. The fix's regression test was written, watched fail against the unfixed stylesheet, and only then added. Known scope left out on purpose ------------------------------- Project rows have no favourite. The ask was sessions, and a starred project would need its own storage key, its own tab semantics and its own view filter; half of that is worse than none of it.
1 parent 478bffa commit aedab1d

11 files changed

Lines changed: 1057 additions & 46 deletions

File tree

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

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
WebuiIconMore,
3131
WebuiIconProjectAdd,
3232
WebuiIconSessionPinMark,
33+
WebuiIconSessionStar,
34+
WebuiIconSessionStarMark,
3335
WebuiIconSessionDisclosure,
3436
WebuiIconAgent,
3537
} from "../icons.js";
@@ -228,13 +230,15 @@ export function WebuiProjectList({
228230
onCreateTaskInProject,
229231
error,
230232
pinnedSessions,
233+
starredSessions,
231234
pinnedProjects,
232235
projectNames,
233236
onRenameProject,
234237
onToggleProjectPin,
235238
onArchiveProject,
236239
onRenameSession,
237240
onToggleSessionPin,
241+
onToggleSessionStar,
238242
onArchiveSession,
239243
onForkSession,
240244
onCopySession,
@@ -256,13 +260,16 @@ export function WebuiProjectList({
256260
readonly onCreateTaskInProject?: (project: WebuiProjectGroup) => void;
257261
readonly error?: string;
258262
readonly pinnedSessions?: Readonly<Record<string, boolean>>;
263+
/** Which rows the 收藏 view collects, and which carry the standing star. */
264+
readonly starredSessions?: Readonly<Record<string, boolean>>;
259265
readonly pinnedProjects?: Readonly<Record<string, boolean>>;
260266
readonly projectNames?: Readonly<Record<string, string>>;
261267
readonly onRenameProject?: (project: WebuiProjectGroup) => void;
262268
readonly onToggleProjectPin?: (project: WebuiProjectGroup) => void;
263269
readonly onArchiveProject?: (project: WebuiProjectGroup) => void;
264270
readonly onRenameSession?: (session: WebuiClientSession) => void;
265271
readonly onToggleSessionPin?: (session: WebuiClientSession) => void;
272+
readonly onToggleSessionStar?: (session: WebuiClientSession) => void;
266273
readonly onArchiveSession?: (session: WebuiClientSession) => void;
267274
readonly onForkSession?: (session: WebuiClientSession, createIsolatedWorktree: boolean) => void;
268275
readonly onCopySession?: (session: WebuiClientSession, value: "workspaceDir" | "sessionId") => void;
@@ -401,6 +408,14 @@ export function WebuiProjectList({
401408
disabled: !onToggleSessionPin,
402409
onSelect: () => onToggleSessionPin?.(session),
403410
},
411+
{
412+
kind: "item",
413+
key: "star",
414+
label: starredSessions?.[session.sessionId] ? "取消收藏" : "收藏",
415+
icon: <WebuiIconSessionStar starred={Boolean(starredSessions?.[session.sessionId])} />,
416+
disabled: !onToggleSessionStar,
417+
onSelect: () => onToggleSessionStar?.(session),
418+
},
404419
{
405420
kind: "item",
406421
key: "rename",
@@ -585,12 +600,12 @@ export function WebuiProjectList({
585600
const activeView = viewProp ?? internalView;
586601
const setActiveView = onViewChange ?? setInternalView;
587602
const railTabs = useMemo(
588-
() => selectWebuiRailViewTabs(page.sessions, activity),
589-
[activity, page.sessions],
603+
() => selectWebuiRailViewTabs(page.sessions, activity, starredSessions),
604+
[activity, page.sessions, starredSessions],
590605
);
591606
const viewSessions = useMemo(
592-
() => filterWebuiRailViewSessions(page.sessions, activity, activeView),
593-
[activeView, activity, page.sessions],
607+
() => filterWebuiRailViewSessions(page.sessions, activity, activeView, starredSessions),
608+
[activeView, activity, page.sessions, starredSessions],
594609
);
595610
const activeTab = railTabs.find((entry) => entry.view === activeView);
596611
const activeTabLabel = activeTab?.label ?? "项目";
@@ -637,6 +652,7 @@ export function WebuiProjectList({
637652
activity={activity}
638653
now={now}
639654
pinnedSessions={pinnedSessions}
655+
starredSessions={starredSessions}
640656
heading={activeTabLabel}
641657
emptyLabel={activeTab?.emptyLabel}
642658
preserveOrder
@@ -807,6 +823,7 @@ export function WebuiProjectList({
807823
activity={activity}
808824
now={now}
809825
pinned={Boolean(pinnedSessions?.[session.sessionId])}
826+
starred={Boolean(starredSessions?.[session.sessionId])}
810827
/>
811828
</a>
812829
<div className="webui-session-row-actions">
@@ -829,6 +846,25 @@ export function WebuiProjectList({
829846
<WebuiIconContextPin pinned={Boolean(pinnedSessions?.[session.sessionId])} />
830847
</button>
831848
) : null}
849+
{onToggleSessionStar ? (
850+
<button
851+
type="button"
852+
aria-label={`${starredSessions?.[session.sessionId] ? "取消收藏" : "收藏"}:${sessionLabel(session)}`}
853+
title={starredSessions?.[session.sessionId] ? "取消收藏" : "收藏"}
854+
className="webui-rail-action"
855+
onClick={(event) => {
856+
event.preventDefault();
857+
event.stopPropagation();
858+
onToggleSessionStar(session);
859+
}}
860+
>
861+
{/* Outline until starred, yellow fill after. Unlike the
862+
* pin, this glyph shows the state rather than the
863+
* action: a filled star is not read as "remove"
864+
* anywhere in the world's icon vocabulary. */}
865+
<WebuiIconSessionStar starred={Boolean(starredSessions?.[session.sessionId])} />
866+
</button>
867+
) : null}
832868
{onArchiveSession ? (
833869
<button
834870
type="button"
@@ -884,6 +920,7 @@ export function WebuiProjectList({
884920
activity={activity}
885921
now={now}
886922
pinned={Boolean(pinnedSessions?.[child.sessionId])}
923+
starred={Boolean(starredSessions?.[child.sessionId])}
887924
/>
888925
</a>
889926
<div className="webui-session-row-actions">
@@ -970,19 +1007,22 @@ function SessionActivityMeta({
9701007
activity,
9711008
now,
9721009
pinned = false,
1010+
starred = false,
9731011
}: {
9741012
readonly session: WebuiClientSession;
9751013
readonly activity?: WebuiSessionActivityMap;
9761014
readonly now?: number;
9771015
/** Draws the standing pin mark. Independent of the live state below it. */
9781016
readonly pinned?: boolean;
1017+
/** Draws the standing star mark. Also independent of the live state. */
1018+
readonly starred?: boolean;
9791019
}): ReactElement | null {
980-
// `pinned` joins the guard rather than riding on it. A host that passes
981-
// neither an activity map nor a clock is the rail rendered outside the app
982-
// (SSR fixtures, and any host that does not subscribe) -- exactly the case
983-
// where a pinned row has no running/unread signal to read its state against,
1020+
// `pinned` and `starred` join the guard rather than riding on it. A host that
1021+
// passes neither an activity map nor a clock is the rail rendered outside the
1022+
// app (SSR fixtures, and any host that does not subscribe) -- exactly the case
1023+
// where a marked row has no running/unread signal to read its state against,
9841024
// so swallowing the mark there loses the only thing the row was saying.
985-
if (!activity && now === undefined && !pinned) return null;
1025+
if (!activity && now === undefined && !pinned && !starred) return null;
9861026
const entry = activity?.[session.sessionId];
9871027
const busy = entry?.busy;
9881028
const badge = formatWebuiUnreadBadge(entry?.unread);
@@ -999,6 +1039,17 @@ function SessionActivityMeta({
9991039
<WebuiIconSessionPinMark />
10001040
</span>
10011041
) : null}
1042+
{starred ? (
1043+
<span
1044+
className="webui-rail-star-mark"
1045+
data-webui-star-mark="true"
1046+
title="已收藏"
1047+
aria-label="已收藏"
1048+
role="img"
1049+
>
1050+
<WebuiIconSessionStarMark />
1051+
</span>
1052+
) : null}
10021053
{badge ? (
10031054
<span
10041055
className="webui-rail-unread-badge"
@@ -1033,6 +1084,7 @@ export function WebuiSessionList({
10331084
activity,
10341085
now,
10351086
pinnedSessions,
1087+
starredSessions,
10361088
heading,
10371089
emptyLabel,
10381090
preserveOrder,
@@ -1053,6 +1105,12 @@ export function WebuiSessionList({
10531105
* sort position says nothing about which one the user chose to keep.
10541106
*/
10551107
readonly pinnedSessions?: Readonly<Record<string, boolean>>;
1108+
/**
1109+
* Which rows carry the standing star mark. 收藏 is a flat list, so a starred
1110+
* row has no project furniture to be recognised by and the mark is the only
1111+
* thing distinguishing it from an ordinary one.
1112+
*/
1113+
readonly starredSessions?: Readonly<Record<string, boolean>>;
10561114
/**
10571115
* Overrides the section label. The rail reuses this list for its running and
10581116
* unread views, where "recent tasks" would be a lie -- the list is filtered,
@@ -1141,6 +1199,7 @@ export function WebuiSessionList({
11411199
activity={activity}
11421200
now={now}
11431201
pinned={Boolean(pinnedSessions?.[session.sessionId])}
1202+
starred={Boolean(starredSessions?.[session.sessionId])}
11441203
/>
11451204
</a>
11461205
{session.workspaceDir ? (

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,9 @@ export function WebuiClientFoundationApp(
353353
const [pinnedSessions, setPinnedSessions] = useState<Record<string, boolean>>(
354354
readSessionOverlay("pins"),
355355
);
356+
const [starredSessions, setStarredSessions] = useState<Record<string, boolean>>(
357+
readSessionOverlay("stars"),
358+
);
356359
const [pinnedProjects, setPinnedProjects] = useState<Record<string, boolean>>(
357360
readProjectPins,
358361
);
@@ -560,6 +563,9 @@ export function WebuiClientFoundationApp(
560563
const handleToggleSessionPin = (session: WebuiClientSession) => {
561564
setPinnedSessions(toggleSessionOverlay("pins", session.sessionId));
562565
};
566+
const handleToggleSessionStar = (session: WebuiClientSession) => {
567+
setStarredSessions(toggleSessionOverlay("stars", session.sessionId));
568+
};
563569
const handleArchiveSession = (session: WebuiClientSession) => {
564570
if (!archiveSession) return;
565571
void archiveSession({ id: session.sessionId })
@@ -1185,13 +1191,15 @@ export function WebuiClientFoundationApp(
11851191
onCreateTaskInProject={(project) => startNewTask(project.workspaceDir)}
11861192
error={pageError}
11871193
pinnedSessions={pinnedSessions}
1194+
starredSessions={starredSessions}
11881195
pinnedProjects={pinnedProjects}
11891196
projectNames={projectNames}
11901197
onRenameProject={handleRenameProject}
11911198
onToggleProjectPin={handleToggleProjectPin}
11921199
onArchiveProject={handleArchiveProject}
11931200
onRenameSession={handleRenameSession}
11941201
onToggleSessionPin={handleToggleSessionPin}
1202+
onToggleSessionStar={handleToggleSessionStar}
11951203
onArchiveSession={handleArchiveSession}
11961204
onForkSession={handleForkSession}
11971205
onCopySession={handleCopySession}

‎packages/webui/src/client/icons.tsx‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,72 @@ export function WebuiIconProjectAdd({ className }: WebuiIconProps): ReactElement
247247
return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" className={className}><path d="M8 3C8.27614 3 8.5 3.22386 8.5 3.5V7.5H12.5C12.7761 7.5 13 7.72386 13 8C13 8.27614 12.7761 8.5 12.5 8.5H8.5V12.5C8.5 12.7761 8.27614 13 8 13C7.72386 13 7.5 12.7761 7.5 12.5V8.5H3.5C3.22386 8.5 3 8.27614 3 8C3 7.72386 3.22386 7.5 3.5 7.5H7.5V3.5C7.5 3.22386 7.72386 3 8 3Z" fill="currentColor" /></svg>;
248248
}
249249

250+
/**
251+
* The five-pointed star, outline until starred and a solid fill after.
252+
*
253+
* The one glyph in this file that shows *state* rather than the action its
254+
* button performs, and deliberately so. The pin's two states are a pin and a
255+
* slashed pin because a slash means "remove" across this whole icon
256+
* vocabulary; nobody reads a filled star as "un-star", and the outline/filled
257+
* pair is what Gmail, Mail and Notion all use. Reusing the pin's convention
258+
* here would have made the rail internally consistent and externally wrong.
259+
*
260+
* `currentColor` rather than a literal, because the filled star is yellow and
261+
* that yellow is a token which flips with the theme (`--text_status_banana`).
262+
* Ten vertices: five points alternating with five notches.
263+
*/
264+
export function WebuiIconSessionStar({
265+
className,
266+
starred = false,
267+
}: WebuiIconProps & { starred?: boolean }): ReactElement {
268+
return starred ? (
269+
<svg
270+
width="16"
271+
height="16"
272+
viewBox="0 0 16 16"
273+
fill="none"
274+
aria-hidden="true"
275+
data-webui-star="filled"
276+
className={className}
277+
>
278+
<path d="M8 1.6L9.7 6.05L14.47 6.3L10.76 9.3L12 13.9L8 11.3L4 13.9L5.24 9.3L1.53 6.3L6.3 6.05Z" fill="currentColor" />
279+
</svg>
280+
) : (
281+
<svg
282+
width="16"
283+
height="16"
284+
viewBox="0 0 16 16"
285+
fill="none"
286+
aria-hidden="true"
287+
data-webui-star="outline"
288+
className={className}
289+
>
290+
<path
291+
d="M8 1.6L9.7 6.05L14.47 6.3L10.76 9.3L12 13.9L8 11.3L4 13.9L5.24 9.3L1.53 6.3L6.3 6.05Z"
292+
fill="none"
293+
stroke="currentColor"
294+
strokeWidth="1.4"
295+
strokeLinejoin="round"
296+
/>
297+
</svg>
298+
);
299+
}
300+
301+
/**
302+
* The persistent "this row is a favourite" mark.
303+
*
304+
* Same argument as the pin's mark, and the same place: the star button only
305+
* exists while the row is hovered, so without this a starred session is
306+
* invisible everywhere except the tab the reader has to go and open.
307+
*/
308+
export function WebuiIconSessionStarMark({ className }: WebuiIconProps): ReactElement {
309+
return (
310+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true" className={className}>
311+
<path d="M8 1.6L9.7 6.05L14.47 6.3L10.76 9.3L12 13.9L8 11.3L4 13.9L5.24 9.3L1.53 6.3L6.3 6.05Z" fill="currentColor" />
312+
</svg>
313+
);
314+
}
315+
250316
/**
251317
* The persistent "this row is pinned" mark.
252318
*

0 commit comments

Comments
 (0)