From c779ef06c5617255c1dd4feaef419e91ffdc7e65 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 18 Aug 2026 14:49:23 +0200 Subject: [PATCH] feat(workspaces): add workspace labels (#3510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): workspace labels baseline Existing labels implementation, committed as-is as the baseline for the UI redesign. Server, protocol, catalog store and sync are sound; the UI layer is reworked in the commits that follow. Co-authored-by: Christoph Leiter * feat(app): show workspace labels on the sidebar meta line Labels could be assigned and then appeared nowhere. They now draw as a chip on the workspace meta row, after the host, change request, checks and service, and they can be switched off from the Show submenu like any other item on that line. One palette, not two. `baseColors.workspaceLabel` was ten raw Tailwind 500s spanning a 1.9:1 to 4.5:1 contrast range — the exact set identity-colors.ts was written to replace. Label color is now the identity color of the same name, so a chip, a host badge and a CI check on one row sit in one contrast band. `workspace-labels/colors.ts` and its per-file `labelColorViolet` blocks are gone; `chip.tsx` and `swatch.tsx` are the only places a label color becomes ink. A row resolves its labels against its own host's catalog and nothing else. The cross-host projection answers too, but two hosts may give one name two colors and the merge settles that by target precedence and then alphabetical serverId — a row painted in a color its host never assigned is lying about the one thing the color is for. The meta row's doc comment argued against tinted chrome on that line. The chip is a deliberate exception — everything else there reports state and shares the state colors, a label is a name a person chose — so the comment now says that instead of contradicting the code. Alignment: - The chip is exactly as tall as the line: text at the row's 16pt lineHeight, no vertical padding, so a labelled row is the same height as an unlabelled one. - A label run that opens the line hangs left by the chip's own padding, landing the name on the rail the title above it uses. Mid-line the ground stays in the flow. - The swatch row reaches 44pt through hitSlop growing outward from a 20pt swatch, replacing ten 44pt colored blobs in the manager modal. - The picker's raw ``, `` and `` go through withUnistyles with module-level mappings, at the menu's 14pt. Also fixes a bug this change exposed: `SidebarRowItemsSchema` is a strict object, so adding an item without a key there fails the whole settings write and silently loses every other Show toggle with it. A test now covers each item in SIDEBAR_ROW_ITEMS. Phase 1 of the workspace labels redesign. The filter menu, the picker's interaction model and the manager modal's layout are later phases. Co-authored-by: Christoph Leiter * feat(app): one Labels filter page with tri-state rows Filtering by label cost three levels: Display -> Labels -> Include -> a page of labels, or -> Exclude -> the same page again. It is now one page: every label across every host, one row each, leading with its colour dot. A row cycles off -> include -> exclude -> off. Include is MenuItem's own check; exclude is lucide's X, the check's own pair, at the same 16 in the same trailing slot, so the column does not shift as a row cycles. Exclude also dims the row, because a state you have to identify by its glyph is not one you read at a glance. Unlabelled is a row in the same list, with a hollow ring where the others carry their colour. Match any / all still appears only once two labels are included. Clear is absent, not disabled, when nothing is filtered. Manage labels sits at the bottom below a separator. The whole root row is gone when no host has a label — unless a filter is still active, since the merged catalog only counts online hosts and losing one would otherwise strand a filter that is still hiding workspaces. The page is titled Labels, so its rows stop repeating the word: "Match any", "Match all", "Clear filter". The two Match rows sit directly under the list they qualify and only appear once two labels are included, so the subject is already on screen. All nine locales move together, translated rather than transliterated — Arabic keeps its noun, where "أي" is a determiner that needs one to stay grammatical. SidebarLabelFilter collapses from four collections plus match to one Record plus match: a tri-state row is one value per label, so include and exclude can no longer disagree about the same name. Unlabelled keys off the empty string, which normalizeWorkspaceLabelName makes unreachable for a real label. toggleSidebarLabelSelection, countSidebarLabelSelections, sidebarLabelSelectionIncludes and deduplicateSidebarLabelNames go with the old shape; setLabelFilter becomes cycleLabelFilter and setLabelMatch, so the store owns its own transitions. No migration: labels are unreleased, so the old persisted shape never shipped, and migrateSidebarViewState already resets on a safeParse failure. The e2e manager step asserted "Red label color" on a swatch that Phase 1 renamed to "Red"; the selector follows the code, and the accessibility.color key behind the old string is deleted from all nine locales as unreferenced. Co-authored-by: Christoph Leiter * feat(app): assign labels on the menu's own rails The assign picker was a search box, a create row that unfolded an inline form under it, and hand-rolled label rows whose 44x44 checkbox pushed every dot and name off the rail the rows above them sat on. Nothing lined up with anything. Rows are `MenuItem` now: the colour dot in the 16pt leading slot, assignment as the trailing check the engine already draws for a chosen row. Using the engine's row is the alignment fix; there is no second row implementation to keep in step. Every press toggles and the menu stays open — assigning labels is multi-select, so the dual-target model (`source: "row" | "checkbox"`, `shouldCloseWorkspaceLabelPicker`) is gone along with the model's `close` dependency. You leave by dismissing the menu. Search is deleted. Nobody has enough labels to search, and the field was the thing pushing the list down the surface. `workspaceLabels.search` and `createNamed` go with it across all nine locales; `clearSearch` stays because the manager modal still owns a search field (Phase 4's copy, untouched here). `Create label` moves below a separator at the bottom and opens a page that replaces the list: a name field, the swatch row, one commit row. Picking a colour picks a colour — it no longer creates the label. A failed create keeps the draft and shows the error on the page; a successful one creates, assigns, and returns to the list with the row ticked. Both menus share one page definition. `SidebarWorkspaceMenu` and `SidebarWorkspaceContextMenu` each built the same `pages` memo; they now call `useWorkspaceLabelMenuPages`. Menu engine, all of it forced by putting a text input on a menu page: - `MenuTextField` — the form kit's `FormTextInput` carries a screen's geometry and lands its text 4pt inside the labels above it. This one is drawn as a row's own fill, so the box and the text sit on the row's two rails at once. - `MenuPageDefinition.hoverIntent` — a page you type into is not one the pointer opens or dismisses. Without it the create flyout opened on a pointer passing over the row, and threw the draft away when the mouse drifted off it while the hands were on the keyboard. - The sheet keeps `keyboardBehavior="interactive"`, not `extend`. With `enableDynamicSizing` the largest snap point is the content's own height, so a short page did not grow and the keyboard came up over the field. Caught on an iOS simulator, fixed, and re-shot. - `MenuItem` announces `selected` as `aria-checked`. The deleted checkbox said so; a check drawn and never announced would have been an accessibility regression. Tri-state rows (the filter page's exclude) still cannot announce themselves — menu-wide, still Phase 5's. `sidebar-workspace-row.tsx`'s `isTouchPlatform = platformIsNative || isCompact` is kept deliberately. On a compact web viewport there is no hover, so the row's trailing actions and kebab were unreachable and the labels menu with them; this is the documented `isHovered || isNative || isCompact` shape from CLAUDE.md. Evidence: /tmp/labels-p3-{wide,compact}-{light,dark}-{list,create}.png, the create page on iOS with the keyboard up (/tmp/labels-p3-native-compact-keyboard.png) and the clipped sheet it replaced (-before.png). `packages/app/e2e/browser/workspace-labels.spec.ts` rewritten for the new model, daemon-backed failure coverage kept for both the toggle and the create path, green with the menu unit suites. Co-authored-by: Christoph Leiter * feat(app): edit a label in one operation `workspace.label.rename.request` and `workspace.label.recolor.request` become one `workspace.label.update.request { name, newName?, color? }`. Both old RPCs are deleted outright — no COMPAT shim, because the labels feature is unreleased and neither name has ever been on a wire a user owns. The `workspaceLabels` capability flag stays; that one is how a new app detects an old daemon. Two calls could land half-applied, which left the catalog in a state nobody asked for and the UI with nothing true to say. One catalog commit now applies the name, the colour, or both. Renaming onto a name the host already has is rejected rather than merged, and the check runs inside the planner so a refused collision applies neither field. A label keeping its own key is not colliding with itself, so case-only edits pass. The manager modal follows: - The host selector is `HostFilter`, the pill History and Schedules use. `includeAllHost` is now a prop: a surface that edits one host's catalog cannot carry out "all". - Search moves to the sheet header, where it spans the header's own rails. As a body control it capped at the component's 420pt and left a hole on the right whenever a host filter wasn't beside it. `workspaceLabels.clearSearch` goes with the box that had a clear button. - A row no longer opens an editor. It carries a pencil in its trailing slot, hover-revealed on web and always visible on native and compact, on the canonical plain-View hover pattern with the press on an inner Pressable. - The edit view is a name field, the swatch row, and one Save that issues one update. Delete keeps its confirm and its affected-workspace count, and moves to `outline` — red belongs to the dialog, not to the surface that opens it. - Copy says edit, never rename or recolor, in all nine locales. `workspaceLabelErrorMessage` drops the `requestType=… code=…` the daemon client appends to every RPC failure. That is what a log wants, not what a user reads under a name field. `workspaceLabels.accessibility.color` stays deleted: the swatch's accessible name is already the colour's name, so nothing needed it back. Co-authored-by: Christoph Leiter * chore(app): converge the labels phases The sweep at the end of the labels redesign: the three items earlier phases found and kept out of their own commits, one cross-phase pass, and the docs. `AdaptiveModalSheet`'s close button reaches its target with `hitSlop` instead of `padding`, which had inset its glyph 8pt from the rail the title, the search field and every trailing glyph below it sit on. This is shared chrome, so it moves the close glyph in every sheet in the app; measured on four of them across both paths — the labels manager desktop and compact, the shortcuts dialog, the rename sheet — and each one's trailing rail now agrees with its leading one. `MenuItem` takes `selected: boolean | "mixed"`. A tri-state row was silent to a screen reader: the check announced itself after the last phase, but exclude had no state at all, so an excluded label was indistinguishable from an untouched one. `"mixed"` is ARIA's own third state, the engine still draws the check only for `true`, and the row keeps supplying its own trailing mark — nothing in the menu engine learns what a label is. CLAUDE.md's platform-gating rules said "NEVER use `onPointerEnter`/ `onPointerLeave`", which is the opposite of what `docs/hover.md` and `constants/platform.ts` both mandate. It now names the real rule — hover on a plain `View`, press on a separate inner `Pressable`, and no `onHoverIn`/ `onHoverOut` on a `Pressable` that contains other pressables — and links the doc that owns it rather than restating it. The cross-phase pass turned up one real defect: the replica cache never learned about labels. `StoredWorkspaceSchema` had no `labels`, so a restored workspace came back unlabelled, and since the directory cursor is current on reconnect the daemon has nothing newer to send — the chips stayed gone for the whole session and the filter matched nothing. An optional field, a serializer line, and an assertion in the restore spec. Everything else measured onto its rails already. Left alone and logged: the per-row `Map` in `useWorkspaceLabelDefinitions`, and the display menu closing when the sidebar swaps to its empty state, which is confined to that swap and predates every phase here. `docs/architecture.md` and `docs/data-model.md` described rename and recolor; they now describe the one update commit, which fields it rewrites, and the per-host resolution a row's chips use. `docs/menus.md` documents the third item state where it already documents the other two. Co-authored-by: Christoph Leiter * feat(app): split the labels filter into include and exclude controls The Labels page rotated one row through off -> include -> exclude -> off, which billed the rare operation to the common one: clearing an include cost two presses and routed through exclude, so the sidebar visibly re-filtered into a state nobody asked for on the way out. The row now owns include and a control on its trailing rail owns exclude. Each is one press each way and neither is reached through the other. `cycleLabelFilter` becomes `toggleLabelInclude` and `toggleLabelExclude`; the store's map shape is unchanged, and setting either state clears the other for that label, so including an excluded row is still one press. A row is never both, so the trailing rail holds exactly one 16pt glyph in every state: the check, a permanent `X`, or the same `X` revealed on hover. `X` rather than a circle-slash for the reason Phase 2 settled it — `Ban` fills its box where the check does not, and the column twitches. The control fades rather than mounts and takes its 44pt target from `hitSlop`, so neither revealing it nor changing state moves anything: measured 453-469 on every row, wide and compact. The control carries a tooltip, gated to web where hover exists. It portals into the overlay root above the flyout with no backdrop of its own, and resting on it does not read as the pointer leaving the surface, so the menu stays up. Accessibility: the control is a `checkbox` named "Exclude {label}" with its own checked state, and it stays in the tree at opacity 0 on rows that have no visible mark, so it is reachable without hover. Keyboard focus reveals it, so tabbing to it never means pressing something invisible. The row keeps Phase 5's `selected="mixed"` for exclude. Co-authored-by: Christoph Leiter * feat(app): put include and exclude on their own rails on every label row Phase 6 gave the row include and its trailing rail a hover-revealed exclude. The rotation was gone but the awkwardness was not: `X` is the glyph this app dismisses things with, so on a filter row it reads as delete rather than exclude, and one rail with a changing occupant still means the row's trailing area keeps changing identity as you read down the column. Both controls are now on every row in fixed positions, and state is which one is lit. Nothing appears, disappears, swaps places or shifts: the check box is 429-445 and the ban box 453-469 on an included row, an excluded row and an untouched one alike, wide and compact. Because the ban is always there, include -> exclude is one press instead of two. The row still includes, and the check is that same action named. `Ban` rather than `X`. Phase 2 rejected it because its ink fills its 16pt box where a check's does not, so the column twitched when the two swapped on one rail. Nothing swaps here, so what is left is a weight difference: the ban is drawn at 14 inside a 16pt box, which is the visible optical correction available before it stops being a glyph. The box is what the rail is measured from and it stays 16. Lit is `foreground`, unlit `foregroundExtraMuted`. Not `foregroundMuted`, which is the obvious choice and is wrong here specifically: the Match rows further down the same page carry `MenuItem`'s own check in that colour to mean *selected*, so an unlit check in the same ink would say the opposite thing eight pixels above it. Excluded keeps Phase 2's dimmed row. Accessibility: the row keeps `selected` — `true` include, `"mixed"` exclude — and the two buttons carry no checked state of their own, so a label's state is announced once and its two controls read as the actions they are. Both buttons take a tooltip, gated to web with `enabledOnDesktop={isWeb}`, because on non-compact native `TooltipContent` falls back to a `Modal` with its own backdrop. Deleted with it: the `isHovered || isFocused || isNative || isCompact` reveal, the `opacity: 0` mounting, the hover-tracking `View` and the focus plumbing that existed to keep an invisible control reachable. Net -7 lines of code on this row, all of the growth in comments. The store is unchanged; both buttons and the row press map onto `toggleLabelInclude` / `toggleLabelExclude` as they are. The e2e's hover-reveal and `toHaveCount(0)` steps encoded the deleted behaviour and are replaced by a rail measurement taken in every state — relative to the row, since filtering moves the anchor the menu hangs off, and behind a settle poll, since the flyout scales in and a box read mid-animation is not a rail. Co-authored-by: Christoph Leiter * feat(app): reveal the label filter's controls, and stop the empty state closing the menu Phase 7 put the check and the ban on every label row at all times, which made a resting list of labels read as two columns of buttons with a filter somewhere behind them. A row now shows its own state and nothing else — a marked row its mark, an untouched row nothing — and hovering any row brings both controls up, the mark you have lit and the other one unlit beside it. So the column you read down is the filter, and the row you are pointing at is the one offering to change it. Nothing about the rails changed. The two boxes are laid out in every state and only their ink comes and goes: check 429-445, ban 453-469 on an included row, an excluded row, an untouched one, hovered or not, wide and compact. The reveal is opacity, never mounting, so it cannot move the row out from under the pointer that fired it. Hover is tracked on a plain `View` wrapping the `MenuItem` with the presses on the buttons inside it, since `MenuItem` is itself a `Pressable` (docs/hover.md); each control counts its own keyboard focus as revealed, so tabbing to one never means pressing something invisible; and `pointerEvents` follows visibility so a hidden control cannot be clicked. On native and compact there is no pointer, so both simply stand there. Everything Phase 7 settled is kept: `Ban` at 14 in a 16pt box, `foregroundExtraMuted` for unlit against `foregroundMuted` meaning *selected* eight pixels below, `spacing[2]` between the two with `hitSlop` tiling the gap, the row owning `aria-checked` while the buttons stay plain named actions, and both tooltips gated `enabledOnDesktop={isWeb}`. --- Excluding a label that empties the sidebar closed the display menu. Logged since Phase 2, narrowed in Phase 5 to the empty-state swap, owned by nobody. `SidebarLabelFilterEmptyState` was a component rendered *instead of* the list, with its own container and scroll view, and both branches were handed the same header element. Different subtrees, so React unmounted the header on the swap — and the header holds the display menu's trigger, so the popover went with its anchor. The empty state is now a body: both list modes render it where their groups would go, inside the scroll they already own and below the header they already render, and the swap can no longer reach anything above it. Cleared from that same still-open page, the way back out is the swap in reverse and it holds too. `ProjectModeList` was already the right shape — its "no projects yet" body sits below the header — but its header was conditional on `unpinnedProjects.length > 0 || hasActiveHostFilter`, which is the same bug written as a guard: keep the header when there is a filter to undo. The label filter joins that condition rather than getting a second mechanism. Both empty states move into `sidebar/empty-states.tsx` so the two list modes can render either without importing each other, which also leaves the card's geometry defined once instead of once per body. Still conditional, and left alone: a sidebar with no projects and no filter has no header at all, so clearing the last host filter on an empty sidebar closes the menu the same way. That one is the onboarding empty state, where a section heading over "Add a project" would be a heading over nothing, and no menu is reachable in the state it lands in. The e2e gets the step that would have caught this: exclude into the empty state, assert the page you pressed on is still up, clear from it, assert the same. Verified by reverting both list files to Phase 7 and watching it fail on "element(s) not found". Phase 7's rail assertions are kept as they are — offsets within the row, behind a settle poll — and joined by computed-opacity reads for the four states of the reveal. Evidence: /tmp/labels-qa-p8/, 28 shots plus INDEX.md — the resting list, a hovered row, an included row with the pointer away, and the empty state with the menu still open over it, at wide and compact in both schemes. Co-authored-by: Christoph Leiter * feat(app): make the label filter include-only Phases 2, 6, 7 and 8 each tried to give exclusion a comfortable shape inside a 232pt menu row: tri-state rotation, a hover-revealed control, two permanent buttons, two hover-or-lit buttons. Every fix bought a new awkwardness, because two controls in one menu row is a lot of machinery for a filter. The label filter is now the host filter — one tap in, one tap out — and nobody has ever complained about that one. A row is a plain `MenuItem` with the colour dot leading and the engine's own check trailing. Nothing else. Which makes the alignment law trivial to satisfy, and that is the point. Deleted rather than disabled, since 6, 7 and 8 are in the branch history if exclusion ever comes back: both trailing controls, the hover-tracking `View`, the `opacity: 0` mounting, the focus/blur plumbing, the `pointerEvents` gating, both tooltips and their `enabledOnDesktop={isWeb}` gates, the lit/unlit token pair, `Ban`, and the two rails they held. `SidebarLabelFilter` collapses onto the shape of `hostFilters` sitting three fields above it in the same store: `labels: string[]` plus `match`, one `toggleLabelFilter`, one `clearLabelFilter`. The exclude arm of `filterWorkspacesByLabels` goes with it, and `filter.include` / `includeLabel` / `exclude` / `excludeLabel` leave all nine locales. Persisted version 4 with no migration — the record shape never shipped, and `createValidatedPersistStorage` drops state that fails the current schema before `migrate` ever runs. Two things this leaves unused, both deleted: - `MenuItem`'s `selected: boolean | "mixed"`, added in Phase 5 so a tri-state row could announce itself. The filter row was the only caller, so the union collapses back to `boolean` and the two `selected === true` guards that existed for `"mixed"` go back to plain truthiness. - The `docs/menus.md` paragraphs from Phases 7 and 8 on hover-revealing a pressable trailing control inside a menu row. No caller does that any more, so the doc was describing a pattern the codebase no longer has. Kept: `SIDEBAR_UNLABELLED_LABEL_KEY`, because `Unlabelled` is still a row and now includes like any other; `Match any` / `Match all`, because multi-select include still means two different things. Phase 8's empty-state fix is untouched and still holds — `hasActiveSidebarLabelFilter` is what the header's survival condition reads, and it answers the same question against the new shape. Evidence: /tmp/labels-qa-p9/, 12 shots plus INDEX.md — resting, two selected, and the empty state with the menu still open over it, at wide and compact in both schemes. Co-authored-by: Christoph Leiter * fix(app): give label-grouped rows their project icons, and label groups a tag Grouping the sidebar by label rendered every workspace row with its placeholder initial. The same workspace under status or project grouping showed its real project icon. The components were reused; the data feeding them was not. One component served both grouping modes and threaded the icon map through correctly. Fourteen lines above it, a second `useProjectIcons` call was gated on `groupMode === "status"`, written when status was the only mode that put icons on rows. Phase 2 extended the render branch to `status || label` and left the gate alone. Nothing forces a render branch and the data it renders to agree, and these two were far enough apart to drift in silence. Reuse buys you one implementation of the drawing; it buys you nothing about who decides what to fetch. So the fix is the shape, not the line. There is now one `useProjectIcons` call for every mode, and its targets come from `buildSidebarProjection` — the same function that produced the rows, from the same `projects` list. A mode can only render a row for a project the projection already knows about, so the map cannot disagree with the screen. The gate is gone rather than extended: extending it would leave the identical trap for the next grouping mode. `useProjectIcons` dedupes by `serverId:projectId` and caches through `useQueries`, so the gate was a premature optimization that bought a bug. Pinned by `sidebar-projection.test.ts`: for each of the three modes, every project view key the projection will render — pinned rows, grouped rows, project headers and the rows under them — has an icon target. Red on the old gate: restoring `groupMode === "status" ? … : []` in its new home fails project and label grouping with `['project', 'other-project']` uncovered, and status passes, which is exactly the bug's signature. The sibling sweep over everything `SidebarStatusModeWrapper` is handed found one more of the same shape, correct today: the projection chose its shortcut sections with `status || label`, so a fourth mode would have silently walked the project rows. Both the groups and the sections now come from one exhaustive `switch` on the mode, which makes the next mode a compile error rather than an empty list. `workspaceEntriesByKey` is already written the safe way round (`!== "project"`), and the rest — host badges, pinning support, the empty-filter state — never read the mode at all. Two names moved with the shape: `SidebarStatusModeWrapper` is `SidebarGroupedModeList`, and its branch is `groupMode !== "project"`, so a new mode lands in the grouped list instead of the project list's `else`. Also: label group headers draw lucide `Tag` instead of `CircleDot`, in the label's colour, muted for `Unlabelled` — the glyph the Labels menu and the workspace menu already use for this feature. It keeps `StatusGroupIcon`'s 14 with no optical nudge, and that is measured rather than assumed: in the header's leading slot both ink a 14x14 box at the same offset, and the tag lays down 76 ink pixels to the circle's 84, so reaching the corners of its box costs it no weight beside the status glyphs. QA: /tmp/labels-qa-p10/ — wide and compact, light and dark, label grouping and the same workspaces under status grouping, plus the hover chevron swap, the glyph zooms, and a before/after pair taken with the fix reverted to the bug's shape. Co-authored-by: Christoph Leiter * fix(app): align label chip typography Co-authored-by: Christoph Leiter * docs: format architecture module table Co-authored-by: Christoph Leiter * fix(app): narrow workspace labels workflow Co-authored-by: Christoph Leiter * fix(app): mirror bottom sheet test exports Co-authored-by: Christoph Leiter * test(desktop): assert project browse by destination Co-authored-by: Christoph Leiter * fix(app): retain empty projects without label filters Co-authored-by: Christoph Leiter * fix(app): preserve status grouping test contract Co-authored-by: Christoph Leiter * fix(app): retain filters after label load failures Co-authored-by: Christoph Leiter --------- Co-authored-by: Christoph Leiter --- docs/architecture.md | 7 + docs/data-model.md | 27 + docs/menus.md | 22 + .../app/e2e/browser/workspace-labels.spec.ts | 383 ++++++ .../app/e2e/support/helpers/mock-agent.ts | 4 +- .../e2e/support/helpers/project-picker-ui.ts | 8 +- .../app/e2e/support/helpers/seed-client.ts | 6 + .../app/src/components/hosts/host-filter.tsx | 12 +- packages/app/src/components/left-sidebar.tsx | 31 +- .../src/components/sidebar-workspace-list.tsx | 184 +-- .../sidebar/display-preferences/menu.tsx | 264 +++- .../sidebar/display-preferences/model.ts | 18 +- .../display-preferences/row-items.test.ts | 1 + .../sidebar/display-preferences/row-items.ts | 2 + .../src/components/sidebar/empty-states.tsx | 68 ++ .../components/sidebar/sidebar-labels.test.ts | 69 ++ .../src/components/sidebar/sidebar-labels.ts | 42 + .../src/components/sidebar/sidebar-model.tsx | 108 +- .../sidebar/sidebar-projection.test.ts | 77 +- .../components/sidebar/sidebar-projection.ts | 76 +- .../sidebar/sidebar-status-list.tsx | 92 +- .../sidebar/sidebar-workspace-menu.tsx | 68 +- .../sidebar/sidebar-workspace-row-content.tsx | 5 + .../sidebar/sidebar-workspace-row.tsx | 7 +- .../sidebar/workspace-meta-row/index.tsx | 74 +- .../workspace-meta-row/meta-items.test.ts | 37 +- .../sidebar/workspace-meta-row/meta-items.ts | 19 +- packages/app/src/components/ui/menu/index.ts | 2 + .../app/src/components/ui/menu/menu-item.tsx | 102 ++ .../src/components/ui/menu/menu-surface.tsx | 35 +- .../hooks/sidebar-workspaces-view-model.ts | 4 + .../src/hooks/use-settings/storage.test.ts | 16 + .../app/src/hooks/use-settings/storage.ts | 5 + .../hooks/use-sidebar-shortcut-model.test.tsx | 2 +- packages/app/src/i18n/resources/ar.ts | 46 + packages/app/src/i18n/resources/en.ts | 46 + packages/app/src/i18n/resources/es.ts | 49 + packages/app/src/i18n/resources/fr.ts | 50 + packages/app/src/i18n/resources/ja.ts | 47 + packages/app/src/i18n/resources/ko.ts | 46 + packages/app/src/i18n/resources/pt-BR.ts | 49 + packages/app/src/i18n/resources/ru.ts | 47 + packages/app/src/i18n/resources/zh-CN.ts | 46 + .../app/src/runtime/directory-sync/index.ts | 14 + packages/app/src/runtime/host-runtime.ts | 12 +- .../src/runtime/replica-cache/index.test.ts | 4 + .../app/src/runtime/replica-cache/index.ts | 5 + packages/app/src/stores/session-store.ts | 3 + .../sidebar-collapsed-sections-store/index.ts | 10 +- .../state.test.ts | 14 +- .../sidebar-collapsed-sections-store/state.ts | 33 +- .../app/src/stores/sidebar-view-store.test.ts | 65 + packages/app/src/stores/sidebar-view-store.ts | 83 +- packages/app/src/workspace-labels/chip.tsx | 108 ++ .../app/src/workspace-labels/index.test.ts | 374 ++++++ packages/app/src/workspace-labels/index.ts | 254 ++++ .../workspace-labels/internal/host-replica.ts | 48 + .../src/workspace-labels/internal/merge.ts | 28 + .../workspace-labels/internal/picker-model.ts | 25 + .../internal/workflow-model.ts | 328 +++++ .../src/workspace-labels/manager-modal.tsx | 399 +++++++ packages/app/src/workspace-labels/picker.tsx | 287 +++++ packages/app/src/workspace-labels/swatch.tsx | 180 +++ .../app/test-stubs/gorhom-bottom-sheet.ts | 14 + packages/client/src/daemon-client.ts | 95 ++ packages/desktop/e2e/project-picker.spec.ts | 8 +- packages/protocol/src/messages.ts | 125 ++ .../src/messages.workspace-labels.test.ts | 125 ++ packages/protocol/src/workspace-labels.ts | 27 + packages/server/src/server/bootstrap.ts | 7 + packages/server/src/server/session.test.ts | 171 +++ packages/server/src/server/session.ts | 206 ++++ .../server/src/server/websocket-server.ts | 7 + .../src/server/workspace-labels/index.test.ts | 1059 +++++++++++++++++ .../src/server/workspace-labels/index.ts | 30 + .../internal/catalog-store.ts | 259 ++++ .../workspace-labels/internal/sequence.ts | 111 ++ .../workspace-labels/internal/service.ts | 298 +++++ .../src/server/workspace-registry.test.ts | 52 + .../server/src/server/workspace-registry.ts | 222 +++- 80 files changed, 7031 insertions(+), 362 deletions(-) create mode 100644 packages/app/e2e/browser/workspace-labels.spec.ts create mode 100644 packages/app/src/components/sidebar/empty-states.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-labels.test.ts create mode 100644 packages/app/src/components/sidebar/sidebar-labels.ts create mode 100644 packages/app/src/workspace-labels/chip.tsx create mode 100644 packages/app/src/workspace-labels/index.test.ts create mode 100644 packages/app/src/workspace-labels/index.ts create mode 100644 packages/app/src/workspace-labels/internal/host-replica.ts create mode 100644 packages/app/src/workspace-labels/internal/merge.ts create mode 100644 packages/app/src/workspace-labels/internal/picker-model.ts create mode 100644 packages/app/src/workspace-labels/internal/workflow-model.ts create mode 100644 packages/app/src/workspace-labels/manager-modal.tsx create mode 100644 packages/app/src/workspace-labels/picker.tsx create mode 100644 packages/app/src/workspace-labels/swatch.tsx create mode 100644 packages/protocol/src/messages.workspace-labels.test.ts create mode 100644 packages/protocol/src/workspace-labels.ts create mode 100644 packages/server/src/server/workspace-labels/index.test.ts create mode 100644 packages/server/src/server/workspace-labels/index.ts create mode 100644 packages/server/src/server/workspace-labels/internal/catalog-store.ts create mode 100644 packages/server/src/server/workspace-labels/internal/sequence.ts create mode 100644 packages/server/src/server/workspace-labels/internal/service.ts diff --git a/docs/architecture.md b/docs/architecture.md index 80fd553e8..d630adbcf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -70,6 +70,7 @@ not retain non-Git directories. | `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing | | `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations | | `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents | +| `server/workspace-labels/` | Host-local label catalog, assignment mutations, and explicit subscriptions | | `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | | `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` | | `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation | @@ -105,6 +106,7 @@ Cross-platform React Native app that connects to one or more daemons. - `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state - `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false. - `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones. +- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication. - `SessionContext` wraps the daemon client for the active session - Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/` - Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication @@ -125,6 +127,11 @@ generation. The daemon retains only the latest projection per entity and bounded event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are independent records; a project with no workspaces does not need a workspace placeholder. +Workspace label definitions use a separate, explicitly subscribed sequence. The list request both +fetches and grants live updates for that session. A current cursor receives an empty correlated +catch-up response when nothing changed; idle sessions and unsubscribed sessions receive no label +traffic. Workspace assignments stay on the workspace directory sequence. + ### `packages/cli` — Command-line client Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`). diff --git a/docs/data-model.md b/docs/data-model.md index 6fd19b34a..4da3abb25 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -57,6 +57,8 @@ $PASEO_HOME/ ├── projects/ │ ├── projects.json # Project registry │ ├── workspaces.json # Workspace registry +│ ├── workspace-labels.json # Shared host-local label catalog +│ ├── workspace-labels.transaction.json # Recoverable catalog/assignment compound commit │ └── icons/ # Host-local custom project icon images ├── runtime/ │ └── managed-processes/ @@ -445,10 +447,35 @@ Array of workspace records. A workspace is a specific working directory within a | `updatedAt` | `string` (ISO 8601) | | | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | | `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. | +| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. | | `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | > **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity. +### Workspace label catalog + +**Path:** `$PASEO_HOME/projects/workspace-labels.json` + +The catalog is shared by every workspace on one host. A definition contains a display name and one +of the ten identity colour names (`WORKSPACE_LABEL_COLORS` in +`packages/protocol/src/workspace-labels.ts`); trimmed, collapsed, case-insensitive name identity is +unique within that file. Definitions have no portable ID or principal owner and remain after their +last assignment. Editing a label takes a new name, a new colour, or both in one commit, so the two +fields cannot land half-applied. Workspaces store label names, so a rename and a delete rewrite +workspace assignments through one serialized compound commit while a recolour is catalog-only; a +rename onto a name the host already has is refused rather than merged. A prepared +`workspace-labels.transaction.json` contains both before and after images. The daemon writes the +catalog and workspace files, then atomically changes the transaction to committed; that phase +change is the durable commit point. Recovery rolls prepared transactions back before either +directory is served. A committed marker proves both data files were already written, so recovery +only loads the current catalog and retries marker cleanup; it never reapplies stale workspace +after-images over later registry mutations. A request rejected before the commit point therefore +cannot take effect after restart. If the live daemon cannot determine or restore the durable state, +the workspace registry freezes every write and later label mutations fail with +`workspace_label_storage_uncertain` until daemon restart performs recovery. Reads remain available, +but may reflect the last acknowledged cache until restart. Workspace directory and catalog updates +publish only after the commit point; publication failure does not roll durable state back. + `projectId` is still a real FK: workspace records should have a matching project record. Read-only history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot blank the whole History screen, but mutation paths should repair or remove the orphaned state rather diff --git a/docs/menus.md b/docs/menus.md index 6e9ef6d2d..3ac9b9fb6 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -77,6 +77,25 @@ Opening a submenu truncates the path to the depth of the trigger that opened it. sliding the pointer across a row of triggers would stack up every flyout it passed instead of swapping between them. +## A page that takes input + +A menu page can hold a small form — `MenuTextField` is the field for it, drawn as a row's own +fill so its text lands on the same rail as the labels above it. Two things have to be true for +that page, and both are set on the page definition rather than discovered later: + +- **`hoverIntent: false`.** A branch you skim is opened and closed by the pointer; a form is not. + Without this the page opens on a pointer that was only passing through, and dismisses itself — + draft and all — the moment your hands move to the keyboard and the mouse drifts off the flyout. + While such a page is open, the whole surface stops closing on hover, because the parent flyout + leads back to the same dismissal. +- **The compact presentation has to be a sheet.** `MenuTextField` resolves to + `BottomSheetTextInput` on compact native, which reads the sheet's context and has none in a + popover. + +The sheet keeps `keyboardBehavior="interactive"`. `extend` grows a sheet to its largest snap +point, and with `enableDynamicSizing` that point is the content's own height — a short page does +not grow, and the keyboard comes up over the field you are typing into. + ## Hover intent A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip @@ -101,6 +120,9 @@ opens on press. | `selected` | This is the chosen value | A check, and nothing else | | `active` | This row's submenu is open | The fill, and no check | +`selected` also announces itself as `aria-checked`, so a multi-select page is audible as the list +of on/off things it is. + A selected row does **not** get a background. A check and a fill are two separate claims about the same state, and showing both makes a chosen row compete with the row the pointer is actually on. `showSelectedCheck` moves the check to a reserved leading column when a group needs to stay diff --git a/packages/app/e2e/browser/workspace-labels.spec.ts b/packages/app/e2e/browser/workspace-labels.spec.ts new file mode 100644 index 000000000..ce911719c --- /dev/null +++ b/packages/app/e2e/browser/workspace-labels.spec.ts @@ -0,0 +1,383 @@ +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { getServerId } from "../support/helpers/server-id"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; + +interface SessionEnvelope { + type?: string; + message?: { type?: string; requestId?: string }; +} + +async function readWorkspaceLabels(seeded: Awaited>) { + const workspaces = await seeded.client.fetchWorkspaces(); + for (const workspace of workspaces.entries) { + if (workspace.id === seeded.workspaceId) return workspace.labels?.slice().sort(); + } + return undefined; +} + +async function installWorkspaceLabelMutationFailure(page: import("@playwright/test").Page) { + let failNextAssignment = false; + let failNextUpdate = false; + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((message) => { + const raw = typeof message === "string" ? message : message.toString("utf8"); + let envelope: SessionEnvelope | null = null; + try { + envelope = JSON.parse(raw) as SessionEnvelope; + } catch { + server.send(message); + return; + } + const request = envelope?.type === "session" ? envelope.message : null; + const failing = + (failNextAssignment && request?.type === "workspace.label.assignment.set.request") || + (failNextUpdate && request?.type === "workspace.label.update.request"); + if (failing && request?.type && request.requestId) { + failNextAssignment = false; + failNextUpdate = false; + browser.send( + JSON.stringify({ + type: "session", + message: { + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: "Injected label mutation failure.", + code: "handler_error", + }, + }, + }), + ); + return; + } + server.send(message); + }); + server.onMessage((message) => browser.send(message)); + }); + return { + failNextAssignment() { + failNextAssignment = true; + }, + failNextUpdate() { + failNextUpdate = true; + }, + }; +} + +function labelRow(page: import("@playwright/test").Page, name: string) { + return page.getByTestId(`workspace-label-picker-row-${name}`); +} + +async function box(locator: import("@playwright/test").Locator) { + const measured = await locator.boundingBox(); + if (!measured) throw new Error("expected a visible element to measure"); + return measured; +} + +async function size(locator: import("@playwright/test").Locator) { + await settled(locator); + const { width, height } = await box(locator); + return { width, height }; +} + +/** + * Waits until an element stops moving. + * + * The flyout scales in and the sidebar behind it re-lays out as the filter changes, so a rail + * measured mid-animation is not a rail — and a measurement built from two `boundingBox()` calls + * would take its two halves from two different frames. + */ +async function settled(locator: import("@playwright/test").Locator) { + let previous = ""; + await expect + .poll(async () => { + const current = JSON.stringify(await box(locator)); + const stable = current === previous; + previous = current; + return stable; + }) + .toBe(true); +} + +async function expectAssigned( + page: import("@playwright/test").Page, + name: string, + assigned: boolean, +) { + await expect(labelRow(page, name)).toHaveAttribute("aria-checked", String(assigned)); +} + +async function openWorkspaceLabels(page: import("@playwright/test").Page, workspaceId: string) { + const serverId = getServerId(); + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.hover(); + await page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`).first().click(); + await page.getByTestId(`sidebar-workspace-menu-labels-${serverId}:${workspaceId}`).click(); + await expect(page.getByTestId("workspace-label-picker-create")).toBeVisible(); +} + +/** + * Creating is its own page: the list is replaced by a name field and the swatches, and one row + * commits. Picking a colour must not create anything on its own. + */ +async function createLabel( + page: import("@playwright/test").Page, + input: { workspaceId: string; name: string; color: string }, +) { + await openWorkspaceLabels(page, input.workspaceId); + await page.getByTestId("workspace-label-picker-create").click(); + await expect(page.getByTestId("workspace-label-picker-create-name")).toBeVisible(); + await page.getByTestId("workspace-label-picker-create-name").fill(input.name); + await page.getByTestId(`workspace-label-swatch-${input.color}`).click(); + await expect(page.getByTestId(`workspace-label-picker-row-${input.name}`)).toBeHidden(); + await page.getByTestId("workspace-label-picker-create-submit").click(); + await expectAssigned(page, input.name, true); + await page.keyboard.press("Escape"); +} + +test.describe("Workspace labels", () => { + test.describe.configure({ timeout: 180_000 }); + + test("keeps label filters reachable when no workspaces match", async ({ page }) => { + const seeded = await seedWorkspace({ repoPrefix: "workspace-labels-no-matches-" }); + try { + await seeded.client.setWorkspaceLabel({ + workspaceId: seeded.workspaceId, + label: { name: "Unused", color: "red" }, + assigned: true, + }); + await seeded.client.setWorkspaceLabel({ + workspaceId: seeded.workspaceId, + label: { name: "Unused", color: "red" }, + assigned: false, + }); + await gotoAppShell(page); + + await page.getByTestId("sidebar-display-preferences-menu").click(); + await page.getByTestId("sidebar-display-label-filter").click(); + // Clear only exists once something is filtered, so the row has to be selected first. + await expect(page.getByTestId("sidebar-label-filter-clear")).toBeHidden(); + await page.getByTestId("sidebar-label-filter-option-Unused").click(); + + await expect(page.getByText("No workspaces match", { exact: true })).toBeVisible(); + await expect(page.getByTestId("sidebar-project-empty-state")).toBeHidden(); + await expect(page.getByTestId("sidebar-display-preferences-menu")).toBeVisible(); + + // Emptying the sidebar swaps the list's body and nothing above it, so the page the filter + // was set on is still up over the empty state and clearing it is one press away. + await expect(page.getByTestId("sidebar-label-filter-clear")).toBeVisible(); + await page.getByTestId("sidebar-label-filter-clear").click(); + await expect( + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`), + ).toBeVisible(); + } finally { + await seeded.cleanup(); + } + }); + + test("creates, multi-assigns, filters, groups, and edits against the daemon", async ({ + page, + }) => { + const seeded = await seedWorkspace({ repoPrefix: "workspace-labels-" }); + const unlabelled = await seedWorkspace({ repoPrefix: "workspace-labels-unlabelled-" }); + try { + const mutationFailure = await installWorkspaceLabelMutationFailure(page); + await gotoAppShell(page); + await createLabel(page, { workspaceId: seeded.workspaceId, name: "Urgent", color: "red" }); + await createLabel(page, { + workspaceId: seeded.workspaceId, + name: "Frontend", + color: "sky", + }); + + await test.step("every press toggles and the picker stays open", async () => { + await openWorkspaceLabels(page, seeded.workspaceId); + await labelRow(page, "Urgent").click(); + await expectAssigned(page, "Urgent", false); + await labelRow(page, "Frontend").click(); + await expectAssigned(page, "Frontend", false); + await labelRow(page, "Urgent").click(); + await labelRow(page, "Frontend").click(); + await expectAssigned(page, "Urgent", true); + await expectAssigned(page, "Frontend", true); + // Four toggles in, the page it started on is still the page it is on. + await expect(page.getByTestId("workspace-label-picker-create")).toBeVisible(); + await page.keyboard.press("Escape"); + + await expect.poll(readWorkspaceLabels.bind(null, seeded)).toEqual(["Frontend", "Urgent"]); + }); + + await test.step("a rejected assignment leaves the row alone and says why", async () => { + await openWorkspaceLabels(page, seeded.workspaceId); + mutationFailure.failNextAssignment(); + await labelRow(page, "Urgent").click(); + await expect(page.getByTestId("workspace-label-picker-error")).toContainText( + "Injected label mutation failure.", + ); + await expectAssigned(page, "Urgent", true); + await page.keyboard.press("Escape"); + }); + + await test.step("a label row filters on one press and clears on the next", async () => { + const labelledRow = page.getByTestId( + `sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`, + ); + const unlabelledRow = page.getByTestId( + `sidebar-workspace-row-${getServerId()}:${unlabelled.workspaceId}`, + ); + await page.getByTestId("sidebar-display-preferences-menu").click(); + await page.getByTestId("sidebar-display-label-filter").click(); + + const urgent = page.getByTestId("sidebar-label-filter-option-Urgent"); + const frontend = page.getByTestId("sidebar-label-filter-option-Frontend"); + const unlabelledLabel = page.getByTestId("sidebar-label-filter-option-unlabelled"); + // A row is one control, so the only thing on it that could move is the check the engine + // draws — measured before anything is filtered, and again once a row carries one. + const atRest = await size(urgent); + + // One press in, one press out, and the row is the whole target either way. + await urgent.click(); + await expect(urgent).toHaveAttribute("aria-checked", "true"); + await expect(labelledRow).toBeVisible(); + await expect(unlabelledRow).toBeHidden(); + expect(await size(urgent)).toEqual(atRest); + + await urgent.click(); + await expect(urgent).toHaveAttribute("aria-checked", "false"); + await expect(labelledRow).toBeVisible(); + await expect(unlabelledRow).toBeVisible(); + expect(await size(urgent)).toEqual(atRest); + + // Unlabelled is a first-class filter option. + await unlabelledLabel.click(); + await expect(labelledRow).toBeHidden(); + await expect(unlabelledRow).toBeVisible(); + + // Selection is deliberately OR-only: labels and Unlabelled together include both rows. + await urgent.click(); + await expect(labelledRow).toBeVisible(); + await expect(unlabelledRow).toBeVisible(); + + // Clear restores the complete sidebar without leaving the filter page. + await page.getByTestId("sidebar-label-filter-clear").click(); + await expect(page.getByTestId("sidebar-label-filter-empty-state")).toBeHidden(); + await expect(page.getByTestId("sidebar-label-filter-clear")).toBeHidden(); + await expect(labelledRow).toBeVisible(); + await expect(unlabelledRow).toBeVisible(); + await expect(urgent).toHaveAttribute("aria-checked", "false"); + await expect(frontend).toHaveAttribute("aria-checked", "false"); + await expect(unlabelledLabel).toHaveAttribute("aria-checked", "false"); + + await page.keyboard.press("Escape"); + }); + + await test.step("editing a label is one operation and preserves its assignments", async () => { + await page.getByTestId("sidebar-display-preferences-menu").click(); + await page.getByTestId("sidebar-display-label-filter").click(); + await page.getByTestId("sidebar-label-manage").click(); + await expect(page.getByText("Manage labels", { exact: true })).toBeVisible(); + + await page.getByTestId("workspace-label-manager-search").fill("urg"); + await expect(page.getByTestId("workspace-label-manager-label-Frontend")).toBeHidden(); + await page.getByTestId("workspace-label-manager-search").fill(""); + await expect(page.getByTestId("workspace-label-manager-label-Frontend")).toBeVisible(); + + // A row is a label, not a button that swaps the surface — the pencil opens the edit, and + // it is only reachable once the row is hovered, the way it is only visible then. + await page.getByTestId("workspace-label-manager-label-Urgent").hover(); + await page.getByTestId("workspace-label-manager-edit-Urgent").click(); + // Phase 1's swatch names a colour by itself; the old "{{color}} label color" string is + // no longer rendered anywhere. + await expect(page.getByRole("radio", { name: "Red" })).toBeChecked(); + await expect(page.getByRole("radio", { name: "Sky" })).not.toBeChecked(); + + await page.getByTestId("workspace-label-manager-name").fill("Priority"); + await page.getByRole("radio", { name: "Amber" }).click(); + // Picking a colour picks a colour: nothing has left for the host yet. + await expect(page.getByTestId("workspace-label-manager-save")).toBeVisible(); + + mutationFailure.failNextUpdate(); + await page.getByTestId("workspace-label-manager-save").click(); + await expect(page.getByTestId("workspace-label-manager-error")).toContainText( + "Injected label mutation failure.", + ); + // Nothing half-applied: the view is still open on the draft exactly as typed. + await expect(page.getByTestId("workspace-label-manager-name")).toHaveValue("Priority"); + await expect(page.getByRole("radio", { name: "Amber" })).toBeChecked(); + + await page.getByTestId("workspace-label-manager-save").click(); + await expect(page.getByTestId("workspace-label-manager-label-Priority")).toBeVisible(); + await expect(page.getByTestId("workspace-label-manager-label-Urgent")).toBeHidden(); + await expect.poll(readWorkspaceLabels.bind(null, seeded)).toEqual(["Frontend", "Priority"]); + }); + + await test.step("a rejected creation keeps the draft and retries in place", async () => { + await page.keyboard.press("Escape"); + await openWorkspaceLabels(page, seeded.workspaceId); + await page.getByTestId("workspace-label-picker-create").click(); + await page.getByTestId("workspace-label-picker-create-name").fill("Retry"); + mutationFailure.failNextAssignment(); + await page.getByTestId("workspace-label-picker-create-submit").click(); + await expect(page.getByTestId("workspace-label-picker-error")).toContainText( + "Injected label mutation failure.", + ); + await expect(page.getByTestId("workspace-label-picker-create-name")).toHaveValue("Retry"); + + await page.getByTestId("workspace-label-picker-create-submit").click(); + await expectAssigned(page, "Retry", true); + await page.keyboard.press("Escape"); + }); + } finally { + await unlabelled.cleanup(); + await seeded.cleanup(); + } + }); + + test.describe("compact picker", () => { + test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + + test("toggles in place and pushes the create page over the list", async ({ page }) => { + const seeded = await seedWorkspace({ repoPrefix: "workspace-labels-touch-" }); + try { + await seeded.client.setWorkspaceLabel({ + workspaceId: seeded.workspaceId, + label: { name: "Touch", color: "red" }, + assigned: true, + }); + await gotoAppShell(page); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await waitForSidebarHydration(page); + + const serverId = getServerId(); + await page.getByTestId(`sidebar-workspace-kebab-${serverId}:${seeded.workspaceId}`).click(); + await page + .getByTestId(`sidebar-workspace-menu-labels-${serverId}:${seeded.workspaceId}`) + .tap(); + await expectAssigned(page, "Touch", true); + + await labelRow(page, "Touch").tap(); + await expectAssigned(page, "Touch", false); + await expect(page.getByTestId("workspace-label-picker-create")).toBeVisible(); + + // The pushed page replaces the list rather than unfolding under it. + await page.getByTestId("workspace-label-picker-create").tap(); + await expect(page.getByTestId("workspace-label-picker-create-name")).toBeVisible(); + await expect(labelRow(page, "Touch")).toBeHidden(); + await page.getByTestId("workspace-label-picker-create-name").fill("Handheld"); + await page.getByTestId("workspace-label-swatch-emerald").tap(); + await page.getByTestId("workspace-label-picker-create-submit").tap(); + + await expectAssigned(page, "Handheld", true); + await expectAssigned(page, "Touch", false); + } finally { + await seeded.cleanup(); + } + }); + }); +}); diff --git a/packages/app/e2e/support/helpers/mock-agent.ts b/packages/app/e2e/support/helpers/mock-agent.ts index bc964c429..6e4a04812 100644 --- a/packages/app/e2e/support/helpers/mock-agent.ts +++ b/packages/app/e2e/support/helpers/mock-agent.ts @@ -84,9 +84,9 @@ export async function openAgentRoute( page: Page, input: { workspaceId: string; agentId: string }, ): Promise { - await page.goto(buildAgentRoute(input.workspaceId, input.agentId)); + await page.goto(buildAgentRoute(input.workspaceId, input.agentId), { waitUntil: "commit" }); await page.waitForURL( (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), - { timeout: 60_000 }, + { timeout: 60_000, waitUntil: "commit" }, ); } diff --git a/packages/app/e2e/support/helpers/project-picker-ui.ts b/packages/app/e2e/support/helpers/project-picker-ui.ts index 9fef26f71..5b81aa03e 100644 --- a/packages/app/e2e/support/helpers/project-picker-ui.ts +++ b/packages/app/e2e/support/helpers/project-picker-ui.ts @@ -1,12 +1,6 @@ import { expect, type Page } from "@playwright/test"; -export async function expectOpenedProject(page: Page, projectName: string): Promise { - const projectRow = page - .locator('[data-testid^="sidebar-project-row-"]') - .filter({ hasText: projectName }) - .first(); - await expect(projectRow).toBeVisible({ timeout: 30_000 }); - +export async function expectOpenedProject(page: Page, _projectName?: string): Promise { await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 }); const projectId = new URL(page.url()).searchParams.get("projectId"); expect(projectId).not.toBeNull(); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index ff4aa0190..d2f02d8f4 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor { projectDisplayName: string; projectRootPath: string; workspaceDirectory: string; + labels?: string[]; } interface SeedProjectDescriptor { @@ -43,6 +44,11 @@ export interface SeedDaemonClient { entries: SeedWorkspaceDescriptor[]; }>; setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>; + setWorkspaceLabel(input: { + workspaceId: string; + label: { name: string; color: "red" }; + assigned: boolean; + }): Promise; listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>; createWorkspace(input: { source: diff --git a/packages/app/src/components/hosts/host-filter.tsx b/packages/app/src/components/hosts/host-filter.tsx index 4456bac22..ffdb6a213 100644 --- a/packages/app/src/components/hosts/host-filter.tsx +++ b/packages/app/src/components/hosts/host-filter.tsx @@ -19,6 +19,11 @@ export interface HostFilterProps { hosts: HostProfile[]; selectedHost: string; onSelectHost: (serverId: string) => void; + /** + * Offer "All hosts". Off for a surface that acts on exactly one host's data — the label + * manager edits a single host's catalog, so "all" is not an answer it could carry out. + */ + includeAllHost?: boolean; triggerTestID?: string; hostOptionTestID?: (serverId: string) => string; } @@ -32,6 +37,7 @@ export function HostFilter({ hosts, selectedHost, onSelectHost, + includeAllHost = true, triggerTestID, hostOptionTestID, }: HostFilterProps): ReactElement { @@ -39,8 +45,8 @@ export function HostFilter({ const filterAnchorRef = useRef(null); const selectedHostLabel = useMemo( - () => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }), - [hosts, selectedHost], + () => getHostPickerLabel(hosts, selectedHost, { includeAllHost }), + [hosts, includeAllHost, selectedHost], ); const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []); @@ -62,7 +68,7 @@ export function HostFilter({ open={isFilterOpen} onOpenChange={setIsFilterOpen} anchorRef={filterAnchorRef} - includeAllHost + includeAllHost={includeAllHost} searchable={false} title="Filter by host" desktopPlacement="bottom-start" diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 64df674d2..3e7ce50fc 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -51,7 +51,8 @@ import { import { useSidebarModel } from "@/components/sidebar/sidebar-model"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { RetainedPanelActivity } from "@/components/retained-panel"; -import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; +import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels"; +import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model"; import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useHosts } from "@/runtime/host-runtime"; @@ -83,9 +84,11 @@ const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || interface SidebarSharedProps { theme: SidebarTheme; - statusGroups: StatusGroup[]; + workspaceGroups: SidebarWorkspaceGroup[]; + projectIconTargets: SidebarProjectIconTarget[]; pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; + hasProjectsBeforeLabelFilter: boolean; workspaceEntriesByKey: ReadonlyMap; isInitialLoad: boolean; isRevalidating: boolean; @@ -140,11 +143,13 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole const { projects, + hasProjectsBeforeLabelFilter, workspaceEntriesByKey, isInitialLoad, isRevalidating, refreshAll, - statusGroups, + workspaceGroups, + projectIconTargets, pinnedGroups, collapsedProjectKeys, toggleProjectCollapsed, @@ -242,9 +247,11 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole const sharedProps = { theme, - statusGroups, + workspaceGroups, + projectIconTargets, pinnedGroups, projects, + hasProjectsBeforeLabelFilter, workspaceEntriesByKey, isInitialLoad, isRevalidating, @@ -595,9 +602,11 @@ function SidebarFooter({ function MobileSidebar({ theme, - statusGroups, + workspaceGroups, + projectIconTargets, pinnedGroups, projects, + hasProjectsBeforeLabelFilter, workspaceEntriesByKey, isInitialLoad, isRevalidating, @@ -712,9 +721,11 @@ function MobileSidebar({ onToggleProjectCollapsed={toggleProjectCollapsed} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} groupMode={groupMode} - statusGroups={statusGroups} + workspaceGroups={workspaceGroups} + projectIconTargets={projectIconTargets} pinnedGroups={pinnedGroups} projects={projects} + hasProjectsBeforeLabelFilter={hasProjectsBeforeLabelFilter} workspaceEntriesByKey={workspaceEntriesByKey} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} @@ -742,9 +753,11 @@ function MobileSidebar({ function DesktopSidebar({ theme, - statusGroups, + workspaceGroups, + projectIconTargets, pinnedGroups, projects, + hasProjectsBeforeLabelFilter, workspaceEntriesByKey, isInitialLoad, isRevalidating, @@ -913,9 +926,11 @@ function DesktopSidebar({ onToggleProjectCollapsed={toggleProjectCollapsed} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} groupMode={groupMode} - statusGroups={statusGroups} + workspaceGroups={workspaceGroups} + projectIconTargets={projectIconTargets} pinnedGroups={pinnedGroups} projects={projects} + hasProjectsBeforeLabelFilter={hasProjectsBeforeLabelFilter} workspaceEntriesByKey={workspaceEntriesByKey} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index f0a180b5c..f73e1c39e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -71,7 +71,11 @@ import { type SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; -import { useSidebarViewStore } from "@/stores/sidebar-view-store"; +import { + hasActiveSidebarLabelFilter, + useSidebarViewStore, + type SidebarGroupMode, +} from "@/stores/sidebar-view-store"; import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges"; import { ContextMenu, @@ -94,7 +98,7 @@ import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reor import { confirmDialog } from "@/utils/confirm-dialog"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list"; -import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; +import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels"; import { SidebarWorkspaceContextMenu, SidebarWorkspaceMenu, @@ -113,12 +117,15 @@ import { SidebarWorkspaceTrailingActionSlot, } from "@/components/sidebar/sidebar-workspace-row-content"; import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility"; +import { + SidebarLabelFilterEmptyState, + SidebarProjectEmptyState, +} from "@/components/sidebar/empty-states"; import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row"; import { SidebarWorkspaceTrailingContent, useSidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; -import { Button } from "@/components/ui/button"; import { PressHighlight } from "@/components/ui/press-highlight"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; @@ -129,9 +136,9 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel, - resolveSidebarProjectIconTargets, resolveSidebarProjectLocalPath, type SidebarProjectHostTarget, + type SidebarProjectIconTarget, } from "@/utils/sidebar-project-row-model"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { openExternalUrl } from "@/utils/open-external-url"; @@ -233,14 +240,17 @@ function selectionForSelectedWorkspace( } interface SidebarWorkspaceListProps { - statusGroups: StatusGroup[]; + workspaceGroups: SidebarWorkspaceGroup[]; + /** What `useProjectIcons` is asked for, straight from the projection. See `SidebarProjection`. */ + projectIconTargets: SidebarProjectIconTarget[]; pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; + hasProjectsBeforeLabelFilter: boolean; workspaceEntriesByKey: ReadonlyMap; collapsedProjectKeys: ReadonlySet; onToggleProjectCollapsed: (projectViewKey: string) => void; shortcutIndexByWorkspaceKey: Map; - groupMode: "project" | "status"; + groupMode: SidebarGroupMode; isRefreshing?: boolean; onRefresh?: () => void; onWorkspacePress?: () => void; @@ -692,6 +702,9 @@ function WorkspaceRowRightGroup({ state.getPinnedWorkspaceOrder); const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder); + const hasActiveLabelFilter = useSidebarViewStore((state) => + hasActiveSidebarLabelFilter(state.labelFilter), + ); const handlePinnedWorkspaceReorder = useCallback( (reorderedWorkspaces: SidebarWorkspacePlacement[]) => { const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); @@ -1973,24 +1991,27 @@ export function SidebarWorkspaceList({ }, [getPinnedWorkspaceOrder, setPinnedWorkspaceOrder], ); - // Status mode drops the project grouping, so its rows carry their own project - // icon. Project mode fetches the same icons inside ProjectModeList for its - // project headers, so only the active mode requests them. - const statusProjectIconTargets = useMemo( - () => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []), - [groupMode, projects], - ); - const statusProjectIconByProjectViewKey = useProjectIcons({ - projects: statusProjectIconTargets, - }); + // One fetch, one map, every mode — project mode paints icons on its headers and status mode + // paints them on each row, all keyed by `projectViewKey`. The targets come from the projection + // that produced the rows, so the question "what is on screen" is answered once. + const projectIconByProjectViewKey = useProjectIcons({ projects: projectIconTargets }); + // A filter that matches nothing swaps the list's body and nothing above it. It used to replace + // this whole subtree, which unmounted the header — and the header is where the display menu's + // trigger lives, so filtering the last row away closed the menu you were filtering from. + const labelFilterEmpty = + hasActiveLabelFilter && hasProjectsBeforeLabelFilter && projects.length === 0; + + // Project mode is the one that keeps its project headers; every other grouping mode is a flat + // list of grouped rows, so a new mode lands in the grouped branch rather than silently in this + // one's `else`. const content = - groupMode === "status" ? ( - @@ -2006,6 +2028,7 @@ export function SidebarWorkspaceList({ projects={projects} pinnedGroups={pinnedGroups} workspaceEntriesByKey={workspaceEntriesByKey} + projectIconByProjectViewKey={projectIconByProjectViewKey} collapsedProjectKeys={collapsedProjectKeys} onToggleProjectCollapsed={onToggleProjectCollapsed} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} @@ -2013,6 +2036,7 @@ export function SidebarWorkspaceList({ onAddProject={onAddProject} listFooterComponent={listFooterComponent} listHeaderComponent={listHeaderComponent} + labelFilterEmpty={labelFilterEmpty} parentGestureRef={parentGestureRef} dragGestureHostPresented={dragGestureHostPresented} pathname={pathname} @@ -2027,8 +2051,14 @@ export function SidebarWorkspaceList({ return content; } -function SidebarStatusModeWrapper({ - statusGroups, +/** + * Every grouping mode except project: the rows are grouped by something that is not a project, so + * each row carries its own project icon. Named for what it does rather than for the first mode + * that needed it — `SidebarStatusModeWrapper` is what made a label-mode reader believe the data + * above it was status-only. + */ +function SidebarGroupedModeList({ + workspaceGroups, pinnedGroups, workspaceEntriesByKey, projectIconByProjectViewKey, @@ -2039,10 +2069,11 @@ function SidebarStatusModeWrapper({ onToggleWorkspacePin, onPinnedWorkspaceReorder, listHeaderComponent, + labelFilterEmpty, parentGestureRef, dragGestureHostPresented, }: { - statusGroups: StatusGroup[]; + workspaceGroups: SidebarWorkspaceGroup[]; pinnedGroups: PinnedSidebarGroups; workspaceEntriesByKey: ReadonlyMap; projectIconByProjectViewKey: ReadonlyMap; @@ -2053,6 +2084,7 @@ function SidebarStatusModeWrapper({ onToggleWorkspacePin: ToggleSidebarWorkspacePin; onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void; listHeaderComponent?: ReactElement | null; + labelFilterEmpty: boolean; parentGestureRef?: MutableRefObject; dragGestureHostPresented?: boolean; }) { @@ -2068,7 +2100,7 @@ function SidebarStatusModeWrapper({ return ( @@ -2089,6 +2122,7 @@ function ProjectModeList({ projects, pinnedGroups, workspaceEntriesByKey, + projectIconByProjectViewKey, collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, @@ -2096,6 +2130,7 @@ function ProjectModeList({ onAddProject, listFooterComponent, listHeaderComponent, + labelFilterEmpty, parentGestureRef, dragGestureHostPresented, pathname, @@ -2104,7 +2139,18 @@ function ProjectModeList({ supportsPinningByServerId, onToggleWorkspacePin, onPinnedWorkspaceReorder, -}: Omit & { +}: Omit< + SidebarWorkspaceListProps, + | "workspaceGroups" + | "projectIconTargets" + | "groupMode" + | "hasProjectsBeforeLabelFilter" + | "isRefreshing" + | "onRefresh" +> & { + /** Swaps the list body for the label filter's empty state. Never the header above it. */ + labelFilterEmpty: boolean; + projectIconByProjectViewKey: ReadonlyMap; pathname: string; hostBadgeByServerId: ReadonlyMap; supportsMultiplicityByServerId: ReadonlyMap; @@ -2113,7 +2159,6 @@ function ProjectModeList({ onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void; }) { const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); - const { t } = useTranslation(); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( new Map(), @@ -2142,7 +2187,6 @@ function ProjectModeList({ canToggle: canTogglePinnedChats, toggleExpanded: togglePinnedChatsExpanded, } = useLimitedSidebarGroup(pinnedChats); - const projectIconTargets = useMemo(() => resolveSidebarProjectIconTargets(projects), [projects]); const nativeScrollGestureProps = useMemo( () => parentGestureRef @@ -2157,10 +2201,6 @@ function ProjectModeList({ [parentGestureRef], ); - const projectIconByProjectViewKey = useProjectIcons({ - projects: projectIconTargets, - }); - useEffect(() => { const timeouts = creatingWorkspaceTimeoutsRef.current; return () => { @@ -2393,6 +2433,26 @@ function ProjectModeList({ ], ); + const projectBody = + projects.length === 0 ? ( + + ) : ( + + ); + const content = ( <> {pinnedChats.length > 0 ? ( @@ -2425,33 +2485,13 @@ function ProjectModeList({ )} ) : null} - {unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null} - {projects.length === 0 ? ( - - - {t("sidebar.project.empty.title")} - - {t("sidebar.project.empty.description")} - - - ) : ( - - )} + {/* The header carries the display menu, which is the only way back out of a filter, so it + stays for as long as a filter is what emptied the list. It is absent only when the + sidebar is genuinely empty, where a section heading would sit over nothing. */} + {unpinnedProjects.length > 0 || hasActiveHostFilter || labelFilterEmpty + ? listHeaderComponent + : null} + {labelFilterEmpty ? : projectBody} {listFooterComponent} ); @@ -2558,28 +2598,6 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 1, color: theme.colors.foreground, }, - emptyContainer: { - marginHorizontal: theme.spacing[2], - marginTop: theme.spacing[4], - paddingTop: theme.spacing[6], - paddingBottom: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface0, - alignItems: "center", - gap: theme.spacing[3], - }, - emptyTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - textAlign: "center", - }, - emptyText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - textAlign: "center", - }, projectRow: { position: "relative", minHeight: 36, diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index b9820b0d4..79351e77b 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -1,9 +1,17 @@ -import { useCallback, useMemo, type ComponentType, type ReactElement, type ReactNode } from "react"; +import { + useCallback, + useMemo, + useState, + type ComponentType, + type ReactElement, + type ReactNode, +} from "react"; import { useTranslation } from "react-i18next"; import { View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Captions, + Circle, CircleCheck, CircleDashed, Clock, @@ -15,6 +23,7 @@ import { Globe, Server, Settings2, + Tag, Type, } from "lucide-react-native"; import { @@ -30,22 +39,40 @@ import { HostStatusDot } from "@/components/host-status-dot"; import { isWeb } from "@/constants/platform"; import { useHosts } from "@/runtime/host-runtime"; import type { Theme } from "@/styles/theme"; -import type { SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { + hasActiveSidebarLabelFilter, + SIDEBAR_UNLABELLED_LABEL_KEY, + type SidebarGroupMode, +} from "@/stores/sidebar-view-store"; +import { workspaceLabelKey, type WorkspaceLabelColor } from "@getpaseo/protocol/workspace-labels"; import type { WorkspaceTitleSource } from "@/hooks/use-settings"; import { SIDEBAR_CHECKS_DISPLAYS, type SidebarChecksDisplay } from "./checks-display"; import { useSidebarDisplayPreferences, type SidebarTrailingChoice } from "./model"; import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items"; +import { useWorkspaceLabelProjection } from "@/workspace-labels"; +import { WorkspaceLabelDot } from "@/workspace-labels/swatch"; +import { WorkspaceLabelManagerModal } from "@/workspace-labels/manager-modal"; const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const ThemedSettings2 = withUnistyles(Settings2); /** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */ const ThemedCircleCheck = withUnistyles(CircleCheck); +const ThemedCircle = withUnistyles(Circle); /** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */ const OPTION_ICON_SIZE = 14; const MENU_WIDTH = 232; +/** + * Unlabelled's stand-in for a color dot: the same circle at the same size, hollow. + * + * 11 rather than the dot's 10 because lucide draws a `size` box and puts a stroked r=10-of-24 + * circle inside it, so the ring's outer edge lands on the dots' edge at 11 and 1pt short at 10. + * The glyphs are what have to agree here, not the boxes they are centred in. + */ +const UNLABELLED_MARK = ; + type OptionIcon = ComponentType<{ size: number; uniProps: (theme: Theme) => { color: string }; @@ -71,6 +98,7 @@ const ROW_ITEM_ICONS: Record = { host: withUnistyles(Server), changeRequest: withUnistyles(GitPullRequest), services: withUnistyles(Globe), + labels: withUnistyles(Tag), }; // These mark how much of the row an option spends, not what CI is, so they are the shapes each @@ -106,6 +134,7 @@ const ROW_ITEM_LABEL_KEYS: Record = { host: "sidebar.display.show.host", changeRequest: "sidebar.display.show.changeRequest", services: "sidebar.display.show.services", + labels: "sidebar.display.show.labels", }; const CHECKS_DISPLAY_LABEL_KEYS: Record = { @@ -130,6 +159,10 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { const { t } = useTranslation(); const preferences = useSidebarDisplayPreferences(); const hosts = useHosts(); + const { labels } = useWorkspaceLabelProjection(); + const [managerOpen, setManagerOpen] = useState(false); + const openManager = useCallback(() => setManagerOpen(true), []); + const closeManager = useCallback(() => setManagerOpen(false), []); const triggerStyle = useCallback( ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ @@ -140,6 +173,10 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { ); const showHostFilter = hosts.length > 1; + // Nothing to filter by means no row at all. The active-filter half is not redundant: the merged + // catalog only counts hosts that are online, so a host dropping off would otherwise take away + // the only way back to a filter that is still hiding workspaces. + const showLabelFilter = labels.length > 0 || hasActiveSidebarLabelFilter(preferences.labelFilter); const pages = useMemo(() => { const definitions: MenuPageDefinition[] = [ @@ -199,59 +236,188 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { content: , }); } + if (showLabelFilter) { + definitions.push({ + id: "labelFilter", + title: t("workspaceLabels.title"), + content: ( + + ), + }); + } return definitions; - }, [t, preferences, hosts, showHostFilter]); + }, [t, preferences, hosts, showHostFilter, showLabelFilter, labels, openManager]); return ( - - - - - - + + - {t("sidebar.display.grouping.label")} - - + + - {t("sidebar.display.titleSource.label")} - - - {t("sidebar.display.show.label")} - - {showHostFilter ? ( - <> - - {/* A filtered sidebar looks like workspaces went missing, so the branch says so + + {t("sidebar.display.grouping.label")} + + + {t("sidebar.display.titleSource.label")} + + + {t("sidebar.display.show.label")} + + {showHostFilter ? ( + <> + + {/* A filtered sidebar looks like workspaces went missing, so the branch says so from the root rather than making you open it to find out. */} - 0} - testID="sidebar-display-host-filter" - > - {t("sidebar.display.hostFilter.label")} - - - ) : null} - - + 0} + testID="sidebar-display-host-filter" + > + {t("sidebar.display.hostFilter.label")} + + + ) : null} + {showLabelFilter ? ( + <> + + + {t("workspaceLabels.title")} + + + ) : null} + + + + + ); +} + +/** + * Every label you could filter by, wherever it lives, one row each. + * + * The catalog is the merged cross-host one on purpose: a workspace row draws its label in its own + * host's color because it would otherwise lie, but this page is the whole set of things to filter + * on and a per-host split would only make you visit it twice. + * + * Clear is absent rather than disabled when it has nothing to act on. + */ +function LabelFilterPage({ + labels, + preferences, + onManage, +}: { + labels: ReturnType["labels"]; + preferences: Preferences; + onManage: () => void; +}): ReactElement { + const { t } = useTranslation(); + const selected = preferences.labelFilter.labels; + return ( + <> + {labels.map((label) => ( + + ))} + + {hasActiveSidebarLabelFilter(preferences.labelFilter) ? ( + <> + + + {t("workspaceLabels.filter.clear")} + + + ) : null} + + + {t("workspaceLabels.manage.open")} + + + ); +} + +/** + * One label: its colour leading, its name, and the engine's own check when it is filtered on. + * + * The same row the host filter uses, because it is the same question — one press in, one press + * out, several at once. Four attempts at giving exclusion a shape in this row are in the branch + * history; each bought a new awkwardness, because two controls in one 232pt menu row is a lot of + * machinery for a filter nobody asked to invert. + */ +function LabelFilterItem({ + name, + label, + color, + selected, + onToggle, + testID, +}: { + /** The filter key this row acts on. Empty for Unlabelled — see `SIDEBAR_UNLABELLED_LABEL_KEY`. */ + name: string; + label: string; + /** `null` is Unlabelled, the one row with no color to stand for. */ + color: WorkspaceLabelColor | null; + selected: boolean; + onToggle: (name: string) => void; + testID: string; +}): ReactElement { + const handleSelect = useCallback(() => onToggle(name), [name, onToggle]); + const leading = useMemo( + () => (color ? : UNLABELLED_MARK), + [color], + ); + + return ( + + {label} + ); } diff --git a/packages/app/src/components/sidebar/display-preferences/model.ts b/packages/app/src/components/sidebar/display-preferences/model.ts index a56077487..cdec3f461 100644 --- a/packages/app/src/components/sidebar/display-preferences/model.ts +++ b/packages/app/src/components/sidebar/display-preferences/model.ts @@ -4,7 +4,11 @@ import { type SidebarWorkspaceTrailing, type WorkspaceTitleSource, } from "@/hooks/use-settings"; -import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { + useSidebarViewStore, + type SidebarGroupMode, + type SidebarLabelFilter, +} from "@/stores/sidebar-view-store"; import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display"; import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items"; @@ -26,6 +30,9 @@ export interface SidebarDisplayPreferences { hostFilters: readonly string[]; toggleHostFilter: (serverId: string) => void; clearHostFilters: () => void; + labelFilter: SidebarLabelFilter; + toggleLabelFilter: (name: string) => void; + clearLabelFilter: () => void; } /** @@ -42,6 +49,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { const hostFilters = useSidebarViewStore((state) => state.hostFilters); const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter); const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters); + const labelFilter = useSidebarViewStore((state) => state.labelFilter); + const toggleLabelFilter = useSidebarViewStore((state) => state.toggleLabelFilter); + const clearLabelFilter = useSidebarViewStore((state) => state.clearLabelFilter); const { settings: { @@ -100,6 +110,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { hostFilters, toggleHostFilter, clearHostFilters, + labelFilter, + toggleLabelFilter, + clearLabelFilter, }), [ grouping, @@ -115,6 +128,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { hostFilters, toggleHostFilter, clearHostFilters, + labelFilter, + toggleLabelFilter, + clearLabelFilter, ], ); } diff --git a/packages/app/src/components/sidebar/display-preferences/row-items.test.ts b/packages/app/src/components/sidebar/display-preferences/row-items.test.ts index 4f1b3ef9f..46ff2f7bc 100644 --- a/packages/app/src/components/sidebar/display-preferences/row-items.test.ts +++ b/packages/app/src/components/sidebar/display-preferences/row-items.test.ts @@ -13,6 +13,7 @@ describe("parseSidebarRowItems", () => { host: true, changeRequest: true, services: true, + labels: true, }); }); diff --git a/packages/app/src/components/sidebar/display-preferences/row-items.ts b/packages/app/src/components/sidebar/display-preferences/row-items.ts index f577e18fd..bd1886111 100644 --- a/packages/app/src/components/sidebar/display-preferences/row-items.ts +++ b/packages/app/src/components/sidebar/display-preferences/row-items.ts @@ -18,6 +18,7 @@ export const SIDEBAR_ROW_ITEMS = [ "host", "changeRequest", "services", + "labels", ] as const; export type SidebarRowItem = (typeof SIDEBAR_ROW_ITEMS)[number]; @@ -31,6 +32,7 @@ export const DEFAULT_SIDEBAR_ROW_ITEMS: SidebarRowItems = { host: true, changeRequest: true, services: true, + labels: true, }; /** diff --git a/packages/app/src/components/sidebar/empty-states.tsx b/packages/app/src/components/sidebar/empty-states.tsx new file mode 100644 index 000000000..72981dcb1 --- /dev/null +++ b/packages/app/src/components/sidebar/empty-states.tsx @@ -0,0 +1,68 @@ +import { Plus } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { useSidebarViewStore } from "@/stores/sidebar-view-store"; + +/** + * The two things the sidebar's workspace list says when it has no rows. + * + * Both are bodies, not screens: a list mode renders one where its groups would go, inside its own + * scroll and below the section header it already owns. The header carries the display menu's + * trigger, and an empty state that replaces the list rather than its body takes the header with + * it — which is how filtering the last row away used to close the menu you were filtering from. + */ +export function SidebarLabelFilterEmptyState() { + const { t } = useTranslation(); + const clearLabelFilter = useSidebarViewStore((state) => state.clearLabelFilter); + + return ( + + {t("workspaceLabels.filter.noMatchesTitle")} + {t("workspaceLabels.filter.noMatchesDescription")} + + + ); +} + +export function SidebarProjectEmptyState({ onAddProject }: { onAddProject?: () => void }) { + const { t } = useTranslation(); + + return ( + + {t("sidebar.project.empty.title")} + {t("sidebar.project.empty.description")} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + marginHorizontal: theme.spacing[2], + marginTop: theme.spacing[4], + paddingTop: theme.spacing[6], + paddingBottom: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface0, + alignItems: "center", + gap: theme.spacing[3], + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + textAlign: "center", + }, + description: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-labels.test.ts b/packages/app/src/components/sidebar/sidebar-labels.test.ts new file mode 100644 index 000000000..eb5abc1c2 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-labels.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "vitest"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { SIDEBAR_UNLABELLED_LABEL_KEY } from "@/stores/sidebar-view-store"; +import { filterWorkspacesByLabels } from "./sidebar-labels"; + +function workspace( + workspaceId: string, + labels: string[], + pinnedAt: string | null = null, +): SidebarWorkspaceEntry { + return { + workspaceKey: `host:${workspaceId}`, + serverId: "host", + workspaceId, + projectViewKey: "project", + projectName: "Project", + projectRootPath: "/repo", + workspaceDirectory: `/repo/${workspaceId}`, + workspaceDirectoryLabel: workspaceId, + projectKind: "git", + workspaceKind: "worktree", + name: workspaceId, + title: null, + pinnedAt, + labels, + currentBranch: "main", + statusBucket: "done", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + prHint: null, + archiveHasUncommittedChanges: null, + archiveUnpushedCommitCount: null, + scripts: [], + hasRunningScripts: false, + }; +} + +describe("sidebar label filtering", () => { + const workspaces = [ + workspace("one", ["Backend", "Urgent"], "2026-01-01"), + workspace("two", ["Backend"]), + workspace("three", []), + ]; + + function filtered(labels: string[]) { + return filterWorkspacesByLabels({ workspaces, labels }).map((entry) => entry.workspaceId); + } + + test("includes a workspace carrying any selected label", () => { + expect(filtered([])).toEqual(["one", "two", "three"]); + expect(filtered(["backend"])).toEqual(["one", "two"]); + expect(filtered(["backend", "urgent"])).toEqual(["one", "two"]); + }); + + test("models Unlabelled as a row in the same list", () => { + expect(filtered([SIDEBAR_UNLABELLED_LABEL_KEY])).toEqual(["three"]); + expect(filtered(["backend", SIDEBAR_UNLABELLED_LABEL_KEY])).toEqual(["one", "two", "three"]); + }); + + test("reads whitespace-only label names as no label rather than as the Unlabelled key", () => { + expect( + filterWorkspacesByLabels({ + workspaces: [workspace("blank", [" "])], + labels: [SIDEBAR_UNLABELLED_LABEL_KEY], + }).map((entry) => entry.workspaceId), + ).toEqual(["blank"]); + }); +}); diff --git a/packages/app/src/components/sidebar/sidebar-labels.ts b/packages/app/src/components/sidebar/sidebar-labels.ts new file mode 100644 index 000000000..0472d27a5 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-labels.ts @@ -0,0 +1,42 @@ +import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { SIDEBAR_UNLABELLED_LABEL_KEY, type SidebarLabelFilter } from "@/stores/sidebar-view-store"; +import type { StatusBucket, StatusGroup } from "@/hooks/sidebar-status-view-model"; + +export interface SidebarWorkspaceGroup { + key: string; + label: string; + rows: SidebarWorkspaceEntry[]; + leading: { kind: "status"; bucket: StatusBucket }; +} + +export function statusWorkspaceGroups(groups: readonly StatusGroup[]): SidebarWorkspaceGroup[] { + return groups.map((group) => ({ + key: group.bucket, + label: group.label, + rows: group.rows, + leading: { kind: "status", bucket: group.bucket }, + })); +} + +/** + * Applies the Labels page's selection to the sidebar. + * + * `Unlabelled` is a row like any other, so it is a key in the same list rather than a boolean + * beside it; the only thing that makes it special is what the key asks of a workspace. + * Selecting several labels includes workspaces carrying any of them. + */ +export function filterWorkspacesByLabels( + input: { workspaces: readonly SidebarWorkspaceEntry[] } & SidebarLabelFilter, +): SidebarWorkspaceEntry[] { + const { workspaces, labels } = input; + if (labels.length === 0) return [...workspaces]; + return workspaces.filter((workspace) => { + // Whitespace-only names normalize away, so `size === 0` is exactly "carries no real label" + // and the empty key can only ever mean Unlabelled. + const keys = new Set((workspace.labels ?? []).map(workspaceLabelKey).filter(Boolean)); + const matches = (key: string) => + key === SIDEBAR_UNLABELLED_LABEL_KEY ? keys.size === 0 : keys.has(key); + return labels.some(matches); + }); +} diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index f9f25bb55..bcc3191f1 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -1,22 +1,33 @@ -import React, { createContext, useContext, useMemo, type ReactNode } from "react"; +import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react"; import { useSidebarWorkspacesList, type SidebarWorkspaceEntry, type SidebarWorkspacesListResult, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarWorkspaceEntries } from "@/hooks/use-sidebar-workspace-entries"; -import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; import { usePinnedSidebarKeys, type PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; -import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { + hasActiveSidebarLabelFilter, + useSidebarViewStore, + type SidebarGroupMode, +} from "@/stores/sidebar-view-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts"; import { buildSidebarProjection } from "./sidebar-projection"; +import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model"; +import { filterWorkspacesByLabels, type SidebarWorkspaceGroup } from "./sidebar-labels"; +import { + hasAuthoritativeWorkspaceLabelCatalog, + useWorkspaceLabelProjection, +} from "@/workspace-labels"; interface SidebarModel extends SidebarWorkspacesListResult { workspaceEntriesByKey: ReadonlyMap; + hasProjectsBeforeLabelFilter: boolean; groupMode: SidebarGroupMode; - statusGroups: StatusGroup[]; + workspaceGroups: SidebarWorkspaceGroup[]; + projectIconTargets: SidebarProjectIconTarget[]; pinnedGroups: PinnedSidebarGroups; collapsedProjectKeys: ReadonlySet; toggleProjectCollapsed: (projectViewKey: string) => void; @@ -24,7 +35,6 @@ interface SidebarModel extends SidebarWorkspacesListResult { } const SidebarModelContext = createContext(null); -const EMPTY_WORKSPACE_ENTRIES = new Map(); export function SidebarModelProvider({ active, @@ -35,57 +45,90 @@ export function SidebarModelProvider({ }) { const list = useSidebarWorkspacesList(); const groupMode = useSidebarViewStore((state) => state.groupMode); + const labelFilter = useSidebarViewStore((state) => state.labelFilter); + const reconcileLabelFilter = useSidebarViewStore((state) => state.reconcileLabelFilter); + const { hosts: labelHosts } = useWorkspaceLabelProjection(); const collapsedProjectKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedProjectKeys, ); - const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( - (state) => state.collapsedStatusGroupKeys, + const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore( + (state) => state.collapsedWorkspaceGroupKeys, ); const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned); const pinnedWorkspaceOrder = useSidebarOrderStore((state) => state.pinnedWorkspaceOrder); const toggleProjectCollapsed = useSidebarCollapsedSectionsStore( (state) => state.toggleProjectCollapsed, ); - const isStatusMode = groupMode === "status"; + const availableLabelNames = useMemo( + () => labelHosts.flatMap((host) => host.labels.map((label) => label.name)), + [labelHosts], + ); + const hasAuthoritativeLabelCatalog = hasAuthoritativeWorkspaceLabelCatalog(labelHosts); + useEffect(() => { + if (!hasAuthoritativeLabelCatalog) return; + reconcileLabelFilter(availableLabelNames); + }, [availableLabelNames, hasAuthoritativeLabelCatalog, reconcileLabelFilter]); + const hasActiveLabelFilter = hasActiveSidebarLabelFilter(labelFilter); + const needsWorkspaceEntries = groupMode !== "project" || hasActiveLabelFilter; const workspaceEntriesByKey = useSidebarWorkspaceEntries( list.workspacePlacements, - active !== false || isStatusMode, + active !== false || needsWorkspaceEntries, ); - const projectionWorkspaceEntriesByKey = isStatusMode - ? workspaceEntriesByKey - : EMPTY_WORKSPACE_ENTRIES; - const pinnedKeys = usePinnedSidebarKeys(list.projects); - const projection = useMemo( - () => - buildSidebarProjection({ - projects: list.projects, - pinnedKeys, - pinnedWorkspaceOrder, - workspaceEntriesByKey: projectionWorkspaceEntriesByKey, - projectNamesByViewKey: list.projectNamesByViewKey, - groupMode, - pinnedCollapsed, - collapsedProjectKeys, - collapsedStatusGroupKeys, - }), + const filteredWorkspaceEntriesByKey = useMemo(() => { + const filtered = filterWorkspacesByLabels({ + workspaces: [...workspaceEntriesByKey.values()], + ...labelFilter, + }); + return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace])); + }, [labelFilter, workspaceEntriesByKey]); + const visibleWorkspaceKeys = useMemo( + () => new Set(filteredWorkspaceEntriesByKey.keys()), + [filteredWorkspaceEntriesByKey], + ); + const filteredProjects = useMemo(() => { + if (!hasActiveLabelFilter) return list.projects; + return list.projects.flatMap((project) => { + const workspaces = project.workspaces.filter((workspace) => + visibleWorkspaceKeys.has(workspace.workspaceKey), + ); + return workspaces.length > 0 ? [{ ...project, workspaces }] : []; + }); + }, [hasActiveLabelFilter, list.projects, visibleWorkspaceKeys]); + const pinnedKeys = usePinnedSidebarKeys(filteredProjects); + const projectionInput = useMemo( + () => ({ + projects: filteredProjects, + pinnedKeys, + pinnedWorkspaceOrder, + workspaceEntriesByKey: filteredWorkspaceEntriesByKey, + projectNamesByViewKey: list.projectNamesByViewKey, + groupMode, + pinnedCollapsed, + collapsedProjectKeys, + collapsedWorkspaceGroupKeys, + }), [ collapsedProjectKeys, - collapsedStatusGroupKeys, + collapsedWorkspaceGroupKeys, groupMode, list.projectNamesByViewKey, - list.projects, + filteredProjects, pinnedCollapsed, pinnedKeys, pinnedWorkspaceOrder, - projectionWorkspaceEntriesByKey, + filteredWorkspaceEntriesByKey, ], ); + const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]); const value = useMemo( () => ({ ...list, - workspaceEntriesByKey, + projects: filteredProjects, + hasProjectsBeforeLabelFilter: list.projects.length > 0, + workspaceEntriesByKey: filteredWorkspaceEntriesByKey, groupMode, - statusGroups: projection.statusGroups, + workspaceGroups: projection.workspaceGroups, + projectIconTargets: projection.projectIconTargets, pinnedGroups: projection.pinnedGroups, collapsedProjectKeys, toggleProjectCollapsed, @@ -95,9 +138,10 @@ export function SidebarModelProvider({ collapsedProjectKeys, groupMode, list, + filteredProjects, projection, toggleProjectCollapsed, - workspaceEntriesByKey, + filteredWorkspaceEntriesByKey, ], ); diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index 5eb82a9a8..55882a0c4 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -6,12 +6,17 @@ import type { } from "@/hooks/use-sidebar-workspaces-list"; import { buildSidebarProjection } from "./sidebar-projection"; -function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBucket"] = "done") { +function makeWorkspace( + id: string, + statusBucket: SidebarWorkspaceEntry["statusBucket"] = "done", + labels: string[] = [], + projectViewKey = "project", +) { const placement: SidebarWorkspacePlacement = { workspaceKey: `srv:${id}`, serverId: "srv", workspaceId: id, - projectViewKey: "project", + projectViewKey, projectName: "Project", projectKind: "git", workspaceKind: "worktree", @@ -32,21 +37,25 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu archiveUnpushedCommitCount: null, scripts: [], hasRunningScripts: false, + labels, }; return { placement, entry }; } -function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry { +function makeProject( + workspaces: SidebarWorkspacePlacement[], + viewKey = "project", +): SidebarProjectEntry { return { - viewKey: "project", + viewKey, projectName: "Project", projectKind: "git", - iconWorkingDir: "/repo", + iconWorkingDir: `/repo/${viewKey}`, hosts: [ { serverId: "srv", - projectId: "project", - iconWorkingDir: "/repo", + projectId: viewKey, + iconWorkingDir: `/repo/${viewKey}`, worktreeSupport: "supported" as const, }, ], @@ -75,11 +84,59 @@ function projectionInput(options?: { groupMode: options?.groupMode ?? ("project" as const), pinnedCollapsed: options?.pinnedCollapsed ?? false, collapsedProjectKeys: new Set(), - collapsedStatusGroupKeys: new Set(), + collapsedWorkspaceGroupKeys: new Set(), + }; +} + +/** + * Two projects, one workspace each, both labelled — so every grouping mode puts rows from more + * than one project on screen, and a mode that asked for fewer icons than it renders would show it. + */ +function twoProjectInput(groupMode: "project" | "status") { + const first = makeWorkspace("first", "running", ["Urgent"], "project"); + const second = makeWorkspace("second", "needs_input", ["Backend"], "other-project"); + return { + ...projectionInput({ groupMode }), + projects: [makeProject([first.placement]), makeProject([second.placement], "other-project")], + pinnedKeys: { pinnedWorkspaceKeys: [], pinnedAtByKey: {} }, + workspaceEntriesByKey: new Map([ + [first.entry.workspaceKey, first.entry], + [second.entry.workspaceKey, second.entry], + ]), + projectNamesByViewKey: new Map([ + ["project", "Project"], + ["other-project", "Other project"], + ]), }; } describe("buildSidebarProjection", () => { + // The rule that outlived the bug it was written for: a project icon is fetched per project, so + // whatever a mode groups by, the rows it produces can only reference projects already covered. + for (const groupMode of ["project", "status"] as const) { + it(`covers every row ${groupMode} grouping renders with a project icon target`, () => { + const projection = buildSidebarProjection(twoProjectInput(groupMode)); + const covered = new Set(projection.projectIconTargets.map((target) => target.projectViewKey)); + + // Every leading visual the sidebar can paint from this projection: pinned rows, grouped + // rows, project headers and the rows under them. + const renderedProjectViewKeys = new Set(); + for (const entry of projection.pinnedGroups.pinnedChats) { + renderedProjectViewKeys.add(entry.projectViewKey); + } + for (const group of projection.workspaceGroups) { + for (const entry of group.rows) renderedProjectViewKeys.add(entry.projectViewKey); + } + for (const project of projection.pinnedGroups.unpinnedProjects) { + renderedProjectViewKeys.add(project.viewKey); + for (const entry of project.workspaces) renderedProjectViewKeys.add(entry.projectViewKey); + } + + expect([...renderedProjectViewKeys].sort()).toEqual(["other-project", "project"]); + expect([...renderedProjectViewKeys].filter((viewKey) => !covered.has(viewKey))).toEqual([]); + }); + } + it("uses one pin-aware projection for project rows and shortcut order", () => { const projection = buildSidebarProjection(projectionInput()); @@ -97,8 +154,8 @@ describe("buildSidebarProjection", () => { it("keeps pinned chats above status groups and removes them from those groups", () => { const projection = buildSidebarProjection(projectionInput({ groupMode: "status" })); - expect(projection.statusGroups.map((group) => group.bucket)).toEqual(["needs_input"]); - expect(projection.statusGroups[0]?.rows.map((entry) => entry.workspaceId)).toEqual([ + expect(projection.workspaceGroups.map((group) => group.key)).toEqual(["needs_input"]); + expect(projection.workspaceGroups[0]?.rows.map((entry) => entry.workspaceId)).toEqual([ "unpinned", ]); expect(projection.shortcutModel.shortcutTargets).toEqual([ diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts index d6c0c0e3a..c67042b45 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.ts @@ -1,4 +1,4 @@ -import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model"; +import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; import { splitPinnedSidebarGroups, type PinnedSidebarGroups, @@ -9,19 +9,33 @@ import type { SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { + resolveSidebarProjectIconTargets, + type SidebarProjectIconTarget, +} from "@/utils/sidebar-project-row-model"; import { buildSidebarShortcutSections, type SidebarShortcutModel, type SidebarShortcutSection, } from "@/utils/sidebar-shortcuts"; +import { statusWorkspaceGroups, type SidebarWorkspaceGroup } from "./sidebar-labels"; export interface SidebarProjection { pinnedGroups: PinnedSidebarGroups; - statusGroups: StatusGroup[]; + workspaceGroups: SidebarWorkspaceGroup[]; + /** + * The project icons this projection needs fetched, keyed by `projectViewKey` — one per project, + * whatever the mode groups by. It sits here rather than beside `useProjectIcons` in the list + * because it is the same `projects` the rows above are projected from: a mode that renders a + * row can only ever ask for an icon this list already covers. It used to be derived in the + * list, under a `groupMode === "status"` gate written when status was the only mode that put + * icons on rows. + */ + projectIconTargets: SidebarProjectIconTarget[]; shortcutModel: SidebarShortcutModel; } -export function buildSidebarProjection(input: { +export interface SidebarProjectionInput { projects: SidebarProjectEntry[]; pinnedKeys: PinnedSidebarKeys; pinnedWorkspaceOrder: string[]; @@ -30,47 +44,63 @@ export function buildSidebarProjection(input: { groupMode: SidebarGroupMode; pinnedCollapsed: boolean; collapsedProjectKeys: ReadonlySet; - collapsedStatusGroupKeys: ReadonlySet; -}): SidebarProjection { + collapsedWorkspaceGroupKeys: ReadonlySet; +} + +export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection { const pinnedGroups = splitPinnedSidebarGroups({ projects: input.projects, keys: input.pinnedKeys, pinnedWorkspaceOrder: input.pinnedWorkspaceOrder, }); const pinnedWorkspaceKeys = new Set(input.pinnedKeys.pinnedWorkspaceKeys); - const statusGroups = - input.groupMode === "status" - ? buildStatusGroups( - Array.from(input.workspaceEntriesByKey.values()).filter( - (workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey), - ), - input.projectNamesByViewKey, - ) - : []; + const unpinnedWorkspaces = Array.from(input.workspaceEntriesByKey.values()).filter( + (workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey), + ); + // One switch decides both what the list groups by and what the keyboard shortcuts walk, so the + // two cannot disagree and a new grouping mode is a compile error here rather than a silent + // fall-through to the project rows. + const workspaceGroups = buildWorkspaceGroups(input, unpinnedWorkspaces); const sections: SidebarShortcutSection[] = []; if (!input.pinnedCollapsed) { sections.push({ workspaces: pinnedGroups.pinnedChats }); } - if (input.groupMode === "status") { - sections.push( - ...statusGroups.map((group) => ({ - workspaces: group.rows, - collapsed: input.collapsedStatusGroupKeys.has(group.bucket), - })), - ); - } else { + if (input.groupMode === "project") { sections.push( ...pinnedGroups.unpinnedProjects.map((project) => ({ workspaces: project.workspaces, collapsed: input.collapsedProjectKeys.has(project.viewKey), })), ); + } else { + sections.push( + ...workspaceGroups.map((group) => ({ + workspaces: group.rows, + collapsed: input.collapsedWorkspaceGroupKeys.has(group.key), + })), + ); } return { pinnedGroups, - statusGroups, + workspaceGroups, + projectIconTargets: resolveSidebarProjectIconTargets(input.projects), shortcutModel: buildSidebarShortcutSections({ sections }), }; } + +/** Project mode keeps its project headers and groups nothing; status mode groups the rows. */ +function buildWorkspaceGroups( + input: SidebarProjectionInput, + unpinnedWorkspaces: SidebarWorkspaceEntry[], +): SidebarWorkspaceGroup[] { + switch (input.groupMode) { + case "project": + return []; + case "status": + return statusWorkspaceGroups( + buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey), + ); + } +} diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 5bd155b15..0436acb7e 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -21,9 +21,12 @@ import type { GestureType } from "react-native-gesture-handler"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; -import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; +import type { StatusBucket } from "@/hooks/sidebar-status-view-model"; +import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels"; +import { SidebarLabelFilterEmptyState } from "@/components/sidebar/empty-states"; import type { HostBadgeModel } from "@/hosts/appearance"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { StyleSheet } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; @@ -112,7 +115,7 @@ function statusWorkspaceKeyExtractor(workspace: SidebarWorkspaceEntry): string { } interface StatusWorkspaceListProps { - groups: StatusGroup[]; + groups: SidebarWorkspaceGroup[]; pinnedWorkspaces: SidebarWorkspaceEntry[]; projectIconByProjectViewKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; @@ -123,6 +126,8 @@ interface StatusWorkspaceListProps { onToggleWorkspacePin: ToggleSidebarWorkspacePin; onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void; listHeaderComponent?: ReactNode; + /** Swaps the group list for the label filter's empty state. Never the header above it. */ + labelFilterEmpty?: boolean; parentGestureRef?: MutableRefObject; dragGestureHostPresented?: boolean; } @@ -139,11 +144,12 @@ export function SidebarStatusWorkspaceList({ onToggleWorkspacePin, onPinnedWorkspaceReorder, listHeaderComponent, + labelFilterEmpty = false, parentGestureRef, dragGestureHostPresented, }: StatusWorkspaceListProps) { - const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( - (state) => state.collapsedStatusGroupKeys, + const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore( + (state) => state.collapsedWorkspaceGroupKeys, ); const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned); const togglePinnedCollapsed = useSidebarCollapsedSectionsStore( @@ -225,17 +231,21 @@ export function SidebarStatusWorkspaceList({ ) : null} {listHeaderComponent} - + {labelFilterEmpty ? ( + + ) : ( + + )} ); @@ -266,7 +276,7 @@ export function SidebarStatusWorkspaceList({ function StatusGroupList({ groups, - collapsedStatusGroupKeys, + collapsedWorkspaceGroupKeys, projectIconByProjectViewKey, shortcutIndex, showShortcutBadges, @@ -275,8 +285,8 @@ function StatusGroupList({ supportsPinningByServerId, onToggleWorkspacePin, }: { - groups: StatusGroup[]; - collapsedStatusGroupKeys: ReadonlySet; + groups: SidebarWorkspaceGroup[]; + collapsedWorkspaceGroupKeys: ReadonlySet; projectIconByProjectViewKey: ReadonlyMap; shortcutIndex: Map; showShortcutBadges: boolean; @@ -289,9 +299,9 @@ function StatusGroupList({ <> {groups.map((group) => ( ; shortcutIndex: Map; @@ -339,7 +349,7 @@ function StatusGroupRows({ {!collapsed ? ( {visibleWorkspaces.map((workspace) => ( ) : null} @@ -393,14 +403,20 @@ function buildStatusRowProjectPresentation({ }; } -function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) { +function StatusGroupHeader({ + group, + collapsed, +}: { + group: SidebarWorkspaceGroup; + collapsed: boolean; +}) { const [isHovered, setIsHovered] = useState(false); - const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore( - (state) => state.toggleStatusGroupCollapsed, + const toggleWorkspaceGroupCollapsed = useSidebarCollapsedSectionsStore( + (state) => state.toggleWorkspaceGroupCollapsed, ); const handlePress = useCallback(() => { - toggleStatusGroupCollapsed(group.bucket); - }, [group.bucket, toggleStatusGroupCollapsed]); + toggleWorkspaceGroupCollapsed(group.key); + }, [group.key, toggleWorkspaceGroupCollapsed]); const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); const rowStyle = useCallback( @@ -417,16 +433,16 @@ function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed @@ -443,16 +459,16 @@ function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed } function StatusGroupLeadingVisual({ - bucket, + leading, collapsed, showChevron, }: { - bucket: StatusGroup["bucket"]; + leading: SidebarWorkspaceGroup["leading"]; collapsed: boolean; showChevron: boolean; }) { if (!showChevron) { - return ; + return ; } if (collapsed) { return ; @@ -460,7 +476,7 @@ function StatusGroupLeadingVisual({ return ; } -function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) { +function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) { switch (bucket) { case "needs_input": return ; @@ -792,7 +808,8 @@ function StatusWorkspaceRowInnerContent({ }: StatusWorkspaceRowInnerProps & { dragInteraction?: ReturnType; }) { - const isTouchPlatform = platformIsNative; + const isCompact = useIsCompactFormFactor(); + const isTouchPlatform = platformIsNative || isCompact; const [isPressed, setIsPressed] = useState(false); const trailing = useSidebarWorkspaceTrailing(); const { @@ -991,6 +1008,9 @@ function StatusWorkspaceActionSlot({ ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -41,6 +56,7 @@ const ThemedPencil = withUnistyles(Pencil); const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedPin = withUnistyles(Pin); const ThemedPinOff = withUnistyles(PinOff); +const ThemedTag = withUnistyles(Tag); const copyLeadingIcon = ; const renameLeadingIcon = ; @@ -62,6 +78,9 @@ function renderTriggerIcon({ hovered }: { hovered?: boolean }) { export interface SidebarWorkspaceMenuProps { workspaceKey: string; + serverId?: string; + workspaceId?: string; + workspaceLabels?: readonly string[]; onCopyPath?: () => void; onCopyBranchName?: () => void; onRename?: () => void; @@ -107,6 +126,8 @@ function WorkspaceMenuItem({ function SidebarWorkspaceMenuItems({ surface, workspaceKey, + serverId, + workspaceId, onCopyPath, onCopyBranchName, onRename, @@ -125,6 +146,10 @@ function SidebarWorkspaceMenuItems({ () => (archiveShortcutKeys ? : null), [archiveShortcutKeys], ); + const labelLeading = useMemo( + () => , + [], + ); return ( <> @@ -178,6 +203,15 @@ function SidebarWorkspaceMenuItems({ {isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")} ) : null} + {serverId && workspaceId ? ( + + {t("workspaceLabels.title")} + + ) : null} ( + () => + serverId && workspaceId ? { serverId, workspaceId, labels: workspaceLabels ?? [] } : null, + [serverId, workspaceId, workspaceLabels], + ); + const pages = useWorkspaceLabelMenuPages(workspaceTarget); return ( {renderTriggerIcon} - + ( + () => ({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + labels: workspace.labels ?? [], + }), + [workspace], + ); + const pages = useWorkspaceLabelMenuPages(workspaceTarget); return ( @@ -327,10 +387,14 @@ export function SidebarWorkspaceContextMenu({ align="start" width={260} testID={`sidebar-workspace-context-menu-${workspaceKey}`} + pages={pages} > ({ color: theme.colors.foregroundMuted }); const needsInputColorMapping = (theme: Theme) => ({ @@ -122,6 +123,9 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte settings: { workspaceTitleSource }, } = useAppSettings(); const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource }); + // The workspace carries label names; their colors live in its host's catalog, so the row is + // where the two meet — the meta line is handed finished definitions. + const labels = useWorkspaceLabelDefinitions(workspace.serverId, workspace.labels); const workspaceBranchTextStyle = useMemo( () => [ styles.workspaceBranchText, @@ -165,6 +169,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte hostBadge={hostBadge ?? null} prHint={workspace.prHint} serviceSummary={serviceSummary} + labels={labels} /> diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index 3bd8dc70a..63355ff7f 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -20,6 +20,7 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { isNative as platformIsNative } from "@/constants/platform"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { SidebarWorkspaceContextMenu, @@ -264,7 +265,8 @@ function WorkspaceRowBody({ onMarkAsRead, archiveShortcutKeys, }: WorkspaceRowBodyProps) { - const isTouchPlatform = platformIsNative; + const isCompact = useIsCompactFormFactor(); + const isTouchPlatform = platformIsNative || isCompact; const [isPressed, setIsPressed] = useState(false); const trailing = useSidebarWorkspaceTrailing(); const draggable = Boolean(drag); @@ -463,6 +465,9 @@ function WorkspaceRowTrailingActions({ ({ color: theme.colors.foreground }); const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged }); @@ -51,13 +59,17 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); /** * The subtitle under a workspace title: which host it lives on, its change request, that - * change request's CI, and any running service. Everything the row knows about a workspace - * that isn't its name. + * change request's CI, any running service, and the labels someone put on it. Everything the + * row knows about a workspace that isn't its name. * * Items are peers separated by a dot rather than ranked by chrome. The host used to be a * tinted pill on the title line, which made it the loudest thing in a row whose subject is - * the title; flattening it lets the whole line read as one piece of secondary text and - * leaves color to mean status. + * the title; flattening it lets the line read as one piece of secondary text. + * + * Labels are the one exception, and a deliberate one. Everything else here reports state, and + * state has its own colors — a label is a name a person chose, so it carries its own identity + * color on a tint instead (see `WorkspaceLabelChip`). It sits last so the reported facts are + * read first, and it stays the same height as the rest of the line. */ export function WorkspaceMetaRow({ currentBranch, @@ -65,12 +77,14 @@ export function WorkspaceMetaRow({ hostBadge, prHint, serviceSummary, + labels = EMPTY_LABELS, }: { currentBranch: string | null; projectName: string | null; hostBadge: HostBadgeModel | null; prHint: PrHint | null; serviceSummary: WorkspaceServiceSummary | null; + labels?: readonly WorkspaceLabelDefinition[]; }) { const { rowItems, checksDisplay } = useSidebarMetaPreferences(); const items = selectMetaRowItems({ @@ -79,6 +93,7 @@ export function WorkspaceMetaRow({ hasHostBadge: hostBadge !== null, prHint, serviceSummary, + labels, visible: rowItems, checksDisplay, }); @@ -90,7 +105,7 @@ export function WorkspaceMetaRow({ {items.map((item, index) => ( {index > 0 ? · : null} - + ))} @@ -100,9 +115,12 @@ export function WorkspaceMetaRow({ function MetaItemNode({ item, hostBadge, + leading, }: { item: MetaRowItem; hostBadge: HostBadgeModel | null; + /** First on the line, so this item's ink sets the rail the title above it already uses. */ + leading: boolean; }): ReactNode { if (item.kind === "branch") { return ; @@ -119,6 +137,9 @@ function MetaItemNode({ if (item.kind === "checks") { return ; } + if (item.kind === "labels") { + return ; + } return ; } @@ -136,6 +157,35 @@ function IdentityItem({ kind, name }: { kind: "branch" | "project"; name: string ); } +/** + * Every label on the workspace, in one run. The chips sit closer to each other than the line's + * items do to each other, so several labels still read as one item rather than as new peers, + * and they take no separator between them — each chip's ground already ends it. + * + * The run shrinks and clips, the way the host badge does. A `+N` counter would be a second + * thing to read on a line that exists to be skimmed. + * + * A workspace whose only meta item is its labels puts a chip where every other row puts a glyph, + * and a chip's ground is chrome — so it hangs left by its own padding and the label's first + * letter lands on the rail the title above it uses. Mid-line the ground stays in the flow: there + * is a separator to its left, and pulling the tint up against that dot buys nothing. + */ +function LabelsItem({ + labels, + leading, +}: { + labels: readonly WorkspaceLabelDefinition[]; + leading: boolean; +}) { + return ( + + {labels.map((label) => ( + + ))} + + ); +} + /** * The only thing on the line that navigates, so it is the only thing that answers the * pointer: it brightens to full foreground and its icon becomes an external-link arrow, @@ -323,6 +373,20 @@ const styles = StyleSheet.create((theme) => ({ lineHeight: 16, flexShrink: 0, }, + // Tighter than the line's own gap so a run of chips reads as one item — see `LabelsItem`. + // Shrinks at the same weight as the service name: both are arbitrary text somebody chose, so + // a crowded line takes from the two of them in proportion rather than emptying one of them. + // The host badge still gives up its space before either — see `flexShrink` in host-badge.tsx. + labels: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + minWidth: 0, + flexShrink: 1, + }, + labelsLeading: { + marginLeft: -WORKSPACE_LABEL_CHIP_INSET, + }, // The one item that gives way when the line runs out of room — see `ServiceItem`. serviceItem: { flexDirection: "row", diff --git a/packages/app/src/components/sidebar/workspace-meta-row/meta-items.test.ts b/packages/app/src/components/sidebar/workspace-meta-row/meta-items.test.ts index 9cf097869..d569ab6f3 100644 --- a/packages/app/src/components/sidebar/workspace-meta-row/meta-items.test.ts +++ b/packages/app/src/components/sidebar/workspace-meta-row/meta-items.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { WorkspaceLabelDefinition } from "@getpaseo/protocol/workspace-labels"; import type { PrHint } from "@/git/pr-hint"; import { DEFAULT_SIDEBAR_CHECKS_DISPLAY } from "@/components/sidebar/display-preferences/checks-display"; import { DEFAULT_SIDEBAR_ROW_ITEMS } from "@/components/sidebar/display-preferences/row-items"; @@ -15,6 +16,8 @@ const PR_HINT: PrHint = { const SERVICE: WorkspaceServiceSummary = { name: "web", health: null }; +const LABELS: WorkspaceLabelDefinition[] = [{ name: "Urgent", color: "red" }]; + function select(overrides: Partial[0]> = {}) { return selectMetaRowItems({ currentBranch: "feature/sidebar-badges", @@ -22,6 +25,7 @@ function select(overrides: Partial[0]> = { hasHostBadge: true, prHint: PR_HINT, serviceSummary: SERVICE, + labels: LABELS, visible: DEFAULT_SIDEBAR_ROW_ITEMS, checksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY, ...overrides, @@ -40,9 +44,14 @@ describe("selectMetaRowItems", () => { "changeRequest", "checks", "services", + "labels", ]); }); + it("reads identity, then the change, then its state, then what is running, then labels", () => { + expect(kinds(select())).toEqual(["host", "changeRequest", "checks", "services", "labels"]); + }); + it("omits what the workspace does not have", () => { expect( kinds( @@ -52,6 +61,7 @@ describe("selectMetaRowItems", () => { hasHostBadge: false, prHint: null, serviceSummary: null, + labels: [], }), ), ).toEqual([]); @@ -64,12 +74,22 @@ describe("selectMetaRowItems", () => { "changeRequest", "checks", "services", + "labels", ]); }); + it("carries every label as one item, so the line keeps one separator however many there are", () => { + const labels: WorkspaceLabelDefinition[] = [ + { name: "Urgent", color: "red" }, + { name: "Backend", color: "sky" }, + ]; + expect(select({ labels }).at(-1)).toEqual({ kind: "labels", labels }); + }); + it.each([ - ["changeRequest", ["host", "checks", "services"]], - ["services", ["host", "changeRequest", "checks"]], + ["changeRequest", ["host", "checks", "services", "labels"]], + ["services", ["host", "changeRequest", "checks", "labels"]], + ["labels", ["host", "changeRequest", "checks", "services"]], ] as const)("drops %s and only %s when it is switched off", (item, expected) => { expect(kinds(select({ visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, [item]: false } }))).toEqual( expected, @@ -77,14 +97,19 @@ describe("selectMetaRowItems", () => { }); it("drops checks and only checks when they are hidden", () => { - expect(kinds(select({ checksDisplay: "none" }))).toEqual(["host", "changeRequest", "services"]); + expect(kinds(select({ checksDisplay: "none" }))).toEqual([ + "host", + "changeRequest", + "services", + "labels", + ]); }); it("keeps checks when the change request is hidden", () => { // Each control answers for itself. A checks setting that drew nothing because a different // switch was off would be lying about its own state. const items = select({ visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false } }); - expect(kinds(items)).toEqual(["host", "checks", "services"]); + expect(kinds(items)).toEqual(["host", "checks", "services", "labels"]); }); it("draws nothing for checks when both are off", () => { @@ -92,7 +117,7 @@ describe("selectMetaRowItems", () => { visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false }, checksDisplay: "none", }); - expect(kinds(items)).toEqual(["host", "services"]); + expect(kinds(items)).toEqual(["host", "services", "labels"]); }); it("carries the resolved check summary rather than the raw hint", () => { @@ -115,6 +140,6 @@ describe("selectMetaRowItems", () => { it("keeps a change request whose forge reports no checks", () => { const items = select({ prHint: { ...PR_HINT, checksStatus: undefined } }); - expect(kinds(items)).toEqual(["host", "changeRequest", "services"]); + expect(kinds(items)).toEqual(["host", "changeRequest", "services", "labels"]); }); }); diff --git a/packages/app/src/components/sidebar/workspace-meta-row/meta-items.ts b/packages/app/src/components/sidebar/workspace-meta-row/meta-items.ts index f15bde443..02f8da577 100644 --- a/packages/app/src/components/sidebar/workspace-meta-row/meta-items.ts +++ b/packages/app/src/components/sidebar/workspace-meta-row/meta-items.ts @@ -1,3 +1,4 @@ +import type { WorkspaceLabelDefinition } from "@getpaseo/protocol/workspace-labels"; import type { PrHint } from "@/git/pr-hint"; import type { SidebarChecksDisplay } from "@/components/sidebar/display-preferences/checks-display"; import type { SidebarRowItems } from "@/components/sidebar/display-preferences/row-items"; @@ -6,8 +7,13 @@ import type { WorkspaceServiceSummary } from "./service-summary"; /** * What ends up on the line under a workspace title, in the order it is read: where the - * workspace lives, what change it belongs to, whether that change is passing, and what it is - * running. Identity first, then the work, then the work's state. + * workspace lives, what change it belongs to, whether that change is passing, what it is + * running, and what someone filed it under. Identity first, then the work, then the work's + * state, then the labels a person put on it. + * + * Labels are one item rather than one per label: they are drawn as a run of chips with a single + * separator in front of them, so the line reads as four peers however many labels a workspace + * carries. */ export type MetaRowItem = | { kind: "branch"; name: string } @@ -15,7 +21,8 @@ export type MetaRowItem = | { kind: "host" } | { kind: "changeRequest"; hint: PrHint } | { kind: "checks"; summary: CheckSummary; label: boolean } - | { kind: "services"; summary: WorkspaceServiceSummary }; + | { kind: "services"; summary: WorkspaceServiceSummary } + | { kind: "labels"; labels: readonly WorkspaceLabelDefinition[] }; /** * Which peers a row should draw, given what it knows and what the user left switched on. @@ -33,6 +40,7 @@ export function selectMetaRowItems(input: { hasHostBadge: boolean; prHint: PrHint | null; serviceSummary: WorkspaceServiceSummary | null; + labels: readonly WorkspaceLabelDefinition[]; visible: SidebarRowItems; checksDisplay: SidebarChecksDisplay; }): MetaRowItem[] { @@ -42,6 +50,7 @@ export function selectMetaRowItems(input: { hasHostBadge, prHint, serviceSummary, + labels, visible, checksDisplay, } = input; @@ -75,5 +84,9 @@ export function selectMetaRowItems(input: { items.push({ kind: "services", summary: serviceSummary }); } + if (labels.length > 0 && visible.labels) { + items.push({ kind: "labels", labels }); + } + return items; } diff --git a/packages/app/src/components/ui/menu/index.ts b/packages/app/src/components/ui/menu/index.ts index d0b06bf14..ee9c72b4a 100644 --- a/packages/app/src/components/ui/menu/index.ts +++ b/packages/app/src/components/ui/menu/index.ts @@ -21,6 +21,8 @@ export { MenuItem, MenuLabel, MenuSeparator, + MenuTextField, + menuRowContentInset, type ActionStatus, type MenuItemProps, } from "./menu-item"; diff --git a/packages/app/src/components/ui/menu/menu-item.tsx b/packages/app/src/components/ui/menu/menu-item.tsx index e3b9f53d9..40286c513 100644 --- a/packages/app/src/components/ui/menu/menu-item.tsx +++ b/packages/app/src/components/ui/menu/menu-item.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, + useState, type PropsWithChildren, type ReactElement, type ReactNode, @@ -14,6 +15,7 @@ import { } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Check, CheckCircle } from "lucide-react-native"; +import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { Theme } from "@/styles/theme"; @@ -126,6 +128,71 @@ export function MenuHint({ ); } +/** + * Where a row's label starts, measured from the surface's edge: the fill's inset, its border and + * its padding. Content a page renders itself — a swatch row, a caption — sits on this rail or it + * reads as a line that slipped out of the list. + */ +export function menuRowContentInset(theme: Theme): number { + return theme.spacing[1] + theme.borderWidth[1] + theme.spacing[2]; +} + +/** + * A text field on a menu page, drawn as the row it stands in for: same box as a row's fill, same + * left rail for the text inside it. The form kit's `FormTextInput` carries a screen's geometry + * (12pt padding, 32pt tall) and lands its text 4pt inside the labels above it, which is exactly + * the misalignment a menu makes obvious. + * + * `AdaptiveTextInput` underneath, so a compact sheet gets `BottomSheetTextInput` and the keyboard + * moves the sheet rather than covering it. That also means the compact presentation has to be a + * sheet: `BottomSheetTextInput` outside a sheet has no context to read. + */ +export function MenuTextField({ + initialValue, + onChangeText, + placeholder, + accessibilityLabel, + autoFocus = false, + editable = true, + onSubmitEditing, + testID, +}: { + initialValue?: string; + onChangeText: (value: string) => void; + placeholder: string; + /** Falls back to `placeholder`, which already names the field. */ + accessibilityLabel?: string; + autoFocus?: boolean; + editable?: boolean; + onSubmitEditing?: () => void; + testID?: string; +}): ReactElement { + const [focused, setFocused] = useState(false); + const handleFocus = useCallback(() => setFocused(true), []); + const handleBlur = useCallback(() => setFocused(false), []); + const fieldStyle = useMemo(() => [styles.field, focused ? styles.fieldFocused : null], [focused]); + return ( + + + + ); +} + function resolveLeadingContent(input: { isPending: boolean | undefined; isSuccess: boolean; @@ -228,6 +295,14 @@ export function MenuItem({ selectItem(onSelect, closeOnSelect); }, [isDisabled, selectItem, onSelect, closeOnSelect]); + // A row that draws a check has to say so as well: a multi-select page is a list of things that + // are on or off, and the check is the only thing telling a sighted user which. Rows that answer + // no such question stay plain buttons. + const accessibilityState = useMemo( + () => (selected === undefined ? undefined : { checked: selected }), + [selected], + ); + const itemPressableStyle = useCallback( ({ pressed, @@ -263,6 +338,8 @@ export function MenuItem({ ({ outlineWidth: 0, outlineColor: "transparent", }, + // The same box as `item`, filled the way a hovered row is filled, so the field sits in the + // column of rows rather than beside it. Every number here is `item`'s: change one, change both. + field: { + flexDirection: "row", + alignItems: "center", + minHeight: MENU_ITEM_HEIGHT, + marginHorizontal: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + fieldFocused: { + borderColor: theme.colors.borderAccent, + }, + fieldInput: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.sm, + lineHeight: MENU_ITEM_LINE_HEIGHT, + paddingVertical: 0, + paddingHorizontal: 0, + }, itemHovered: { backgroundColor: theme.colors.surface2, }, diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 4d3fb5f0d..4f5bb22de 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -69,6 +69,15 @@ export interface MenuPageDefinition { id: string; title: string; content: ReactNode; + /** + * Whether the pointer opens and closes this page on its own. Default true. + * + * A page that takes typed input sets this false: hover intent would open it on a pointer that + * was only passing through, and then dismiss it — draft and all — the moment the hands moved + * to the keyboard and the mouse drifted off the flyout. While such a page is open, the whole + * surface stops closing on hover, since its parent leads back to the same dismissal. + */ + hoverIntent?: boolean; } interface MenuSurfaceContextValue { @@ -210,8 +219,25 @@ function MenuPopoverSurface({ [menu.path, pages], ); + // `hoverIntent: false` takes a page off the pointer entirely — it is not opened by resting on + // its trigger, and while it is open nothing on this surface closes on a pointer leaving it. + const hoverValue = useMemo(() => { + const locked = openPages.some(({ page }) => page.hoverIntent === false); + return { + ...surfaceValue, + hoverOpen: (sub) => { + if (pages.find((page) => page.id === sub.id)?.hoverIntent === false) return; + surfaceValue.hoverOpen(sub); + }, + hoverClose: (depth) => { + if (locked) return; + surfaceValue.hoverClose(depth); + }, + }; + }, [openPages, pages, surfaceValue]); + return ( - + <> { toolCallDetailLevel: "overview", }); }); + + // The row items are written as one object through one strict schema, so an item the schema + // does not know does not just fail to persist itself — it takes every sibling toggle with it. + it.each(SIDEBAR_ROW_ITEMS)("persists the %s row item being switched off", async (item) => { + const deps = makeDeps(); + const queryClient = new QueryClient(); + const sidebarRowItems = { ...DEFAULT_SIDEBAR_ROW_ITEMS, [item]: false }; + + await saveAppSettings({ queryClient, updates: { sidebarRowItems }, deps }); + + expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems).toEqual(sidebarRowItems); + }); }); describe("parseTerminalScrollbackLines", () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 368592e6f..b60ac1938 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -83,12 +83,17 @@ export interface Settings extends AppSettings { releaseChannel: ReleaseChannel; } +// Strict, so every item in SIDEBAR_ROW_ITEMS needs a key here the day it is added: the whole +// settings write is one validation, and one unknown key silently loses every other toggle in it. +// `checks` and `scripts` are gone from the item list and stay here for the COMPAT reads in +// row-items.ts. const SidebarRowItemsSchema = z.strictObject({ branch: z.boolean().optional(), project: z.boolean().optional(), host: z.boolean().optional(), changeRequest: z.boolean().optional(), services: z.boolean().optional(), + labels: z.boolean().optional(), checks: z.boolean().optional(), scripts: z.boolean().optional(), }); diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx index ebb0c5e81..22396b7c5 100644 --- a/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx @@ -69,7 +69,7 @@ describe("useSidebarShortcutModel", () => { root = createRoot(container); useSidebarCollapsedSectionsStore.setState({ collapsedProjectKeys: new Set(), - collapsedStatusGroupKeys: new Set(), + collapsedWorkspaceGroupKeys: new Set(), }); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index a6c92e800..a77aa65e2 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -960,6 +960,50 @@ export const ar: TranslationResources = { }, }, }, + workspaceLabels: { + title: "التسميات", + unlabelled: "بلا تسمية", + create: "إنشاء تسمية", + createConfirm: "إنشاء", + creating: "جارٍ الإنشاء…", + name: "اسم التسمية", + updateHostUse: "حدّث هذا المضيف لاستخدام التسميات.", + errors: { update: "تعذّر تحديث التسمية", load: "تعذّر تحميل التسميات" }, + colors: { + violet: "بنفسجي", + sky: "سماوي", + emerald: "زمردي", + orange: "برتقالي", + pink: "وردي", + indigo: "نيلي", + teal: "أزرق مخضر", + red: "أحمر", + amber: "كهرماني", + blue: "أزرق", + }, + filter: { + clear: "مسح عامل التصفية", + noMatchesTitle: "لا توجد مساحات عمل مطابقة", + noMatchesDescription: "غيّر عامل تصفية التسميات أو امسحه لعرض مساحات العمل.", + }, + manage: { + open: "إدارة التسميات…", + title: "إدارة التسميات", + search: "بحث في التسميات", + empty: "لا توجد تسميات على هذا المضيف.", + edit: "تعديل التسمية", + editLabel: "تعديل {{name}}", + name: "الاسم", + color: "اللون", + save: "حفظ", + delete: "حذف", + deleteTitle: "حذف {{name}}؟", + deleteMessage_one: "سيؤدي هذا إلى إزالة التسمية من {{count}} مساحة عمل على هذا المضيف.", + deleteMessage_other: "سيؤدي هذا إلى إزالة التسمية من {{count}} مساحات عمل على هذا المضيف.", + offline: "هذا المضيف غير متصل.", + updateHost: "حدّث هذا المضيف لإدارة التسميات.", + }, + }, sidebar: { display: { trigger: "تفضيلات العرض", @@ -968,6 +1012,7 @@ export const ar: TranslationResources = { label: "التجميع", project: "المشروع", status: "الحالة", + labels: "التسميات", }, titleSource: { label: "العنوان", @@ -982,6 +1027,7 @@ export const ar: TranslationResources = { changeRequest: "طلب السحب", checks: "الفحوصات", services: "الخدمات", + labels: "التسميات", diff: "إحصائيات الفروق", timestamp: "آخر نشاط", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 686b0e304..9c56e970a 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -969,6 +969,50 @@ export const en = { }, }, }, + workspaceLabels: { + title: "Labels", + unlabelled: "Unlabelled", + create: "Create label", + createConfirm: "Create", + creating: "Creating…", + name: "Label name", + updateHostUse: "Update this host to use labels.", + errors: { update: "Unable to update label", load: "Unable to load labels" }, + colors: { + violet: "Violet", + sky: "Sky", + emerald: "Emerald", + orange: "Orange", + pink: "Pink", + indigo: "Indigo", + teal: "Teal", + red: "Red", + amber: "Amber", + blue: "Blue", + }, + filter: { + clear: "Clear filter", + noMatchesTitle: "No workspaces match", + noMatchesDescription: "Change or clear the label filter to see workspaces.", + }, + manage: { + open: "Manage labels…", + title: "Manage labels", + search: "Search labels", + empty: "No labels on this host.", + edit: "Edit label", + editLabel: "Edit {{name}}", + name: "Name", + color: "Color", + save: "Save", + delete: "Delete", + deleteTitle: "Delete {{name}}?", + deleteMessage_one: "This removes the label from {{count}} workspace on this host.", + deleteMessage_other: "This removes the label from {{count}} workspaces on this host.", + offline: "This host is offline.", + updateHost: "Update this host to manage labels.", + }, + }, sidebar: { display: { trigger: "Display preferences", @@ -977,6 +1021,7 @@ export const en = { label: "Grouping", project: "Project", status: "Status", + labels: "Labels", }, titleSource: { label: "Title", @@ -991,6 +1036,7 @@ export const en = { changeRequest: "Pull request", checks: "Checks", services: "Services", + labels: "Labels", diff: "Diff stats", timestamp: "Last activity", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 6f01b6abf..4ea5b2fa6 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -991,6 +991,53 @@ export const es: TranslationResources = { }, }, }, + workspaceLabels: { + title: "Etiquetas", + unlabelled: "Sin etiqueta", + create: "Crear etiqueta", + createConfirm: "Crear", + creating: "Creando…", + name: "Nombre de la etiqueta", + updateHostUse: "Actualiza este host para usar etiquetas.", + errors: { + update: "No se pudo actualizar la etiqueta", + load: "No se pudieron cargar las etiquetas", + }, + colors: { + violet: "Violeta", + sky: "Celeste", + emerald: "Esmeralda", + orange: "Naranja", + pink: "Rosa", + indigo: "Índigo", + teal: "Verde azulado", + red: "Rojo", + amber: "Ámbar", + blue: "Azul", + }, + filter: { + clear: "Borrar filtro", + noMatchesTitle: "Ningún espacio de trabajo coincide", + noMatchesDescription: "Cambia o borra el filtro de etiquetas para ver espacios de trabajo.", + }, + manage: { + open: "Gestionar etiquetas…", + title: "Gestionar etiquetas", + search: "Buscar etiquetas", + empty: "No hay etiquetas en este host.", + edit: "Editar etiqueta", + editLabel: "Editar {{name}}", + name: "Nombre", + color: "Color", + save: "Guardar", + delete: "Eliminar", + deleteTitle: "¿Eliminar {{name}}?", + deleteMessage_one: "Esto quita la etiqueta de {{count}} espacio de trabajo en este host.", + deleteMessage_other: "Esto quita la etiqueta de {{count}} espacios de trabajo en este host.", + offline: "Este host está desconectado.", + updateHost: "Actualiza este host para gestionar etiquetas.", + }, + }, sidebar: { display: { trigger: "Preferencias de visualización", @@ -999,6 +1046,7 @@ export const es: TranslationResources = { label: "Agrupación", project: "Proyecto", status: "Estado", + labels: "Etiquetas", }, titleSource: { label: "Título", @@ -1013,6 +1061,7 @@ export const es: TranslationResources = { changeRequest: "Pull request", checks: "Comprobaciones", services: "Servicios", + labels: "Etiquetas", diff: "Estadísticas de cambios", timestamp: "Última actividad", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6ab9e49f3..c2daaa0b8 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -990,6 +990,54 @@ export const fr: TranslationResources = { }, }, }, + workspaceLabels: { + title: "Étiquettes", + unlabelled: "Sans étiquette", + create: "Créer une étiquette", + createConfirm: "Créer", + creating: "Création…", + name: "Nom de l’étiquette", + updateHostUse: "Mettez à jour cet hôte pour utiliser les étiquettes.", + errors: { + update: "Impossible de mettre à jour l’étiquette", + load: "Impossible de charger les étiquettes", + }, + colors: { + violet: "Violet", + sky: "Ciel", + emerald: "Émeraude", + orange: "Orange", + pink: "Rose", + indigo: "Indigo", + teal: "Sarcelle", + red: "Rouge", + amber: "Ambre", + blue: "Bleu", + }, + filter: { + clear: "Effacer le filtre", + noMatchesTitle: "Aucun espace de travail ne correspond", + noMatchesDescription: + "Modifiez ou effacez le filtre d’étiquettes pour afficher les espaces de travail.", + }, + manage: { + open: "Gérer les étiquettes…", + title: "Gérer les étiquettes", + search: "Rechercher des étiquettes", + empty: "Aucune étiquette sur cet hôte.", + edit: "Modifier l’étiquette", + editLabel: "Modifier {{name}}", + name: "Nom", + color: "Couleur", + save: "Enregistrer", + delete: "Supprimer", + deleteTitle: "Supprimer {{name}} ?", + deleteMessage_one: "Cela retire l’étiquette de {{count}} espace de travail sur cet hôte.", + deleteMessage_other: "Cela retire l’étiquette de {{count}} espaces de travail sur cet hôte.", + offline: "Cet hôte est hors ligne.", + updateHost: "Mettez à jour cet hôte pour gérer les étiquettes.", + }, + }, sidebar: { display: { trigger: "Préférences d'affichage", @@ -998,6 +1046,7 @@ export const fr: TranslationResources = { label: "Regroupement", project: "Projet", status: "Statut", + labels: "Libellés", }, titleSource: { label: "Titre", @@ -1012,6 +1061,7 @@ export const fr: TranslationResources = { changeRequest: "Pull request", checks: "Vérifications", services: "Services", + labels: "Libellés", diff: "Statistiques de diff", timestamp: "Dernière activité", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 08f2f9575..e263004e6 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -971,6 +971,51 @@ export const ja: TranslationResources = { }, }, }, + workspaceLabels: { + title: "ラベル", + unlabelled: "ラベルなし", + create: "ラベルを作成", + createConfirm: "作成", + creating: "作成中…", + name: "ラベル名", + updateHostUse: "ラベルを使用するにはホストを更新してください。", + errors: { update: "ラベルを更新できません", load: "ラベルを読み込めません" }, + colors: { + violet: "紫", + sky: "空色", + emerald: "エメラルド", + orange: "オレンジ", + pink: "ピンク", + indigo: "藍", + teal: "青緑", + red: "赤", + amber: "琥珀", + blue: "青", + }, + filter: { + clear: "フィルターをクリア", + noMatchesTitle: "一致するワークスペースがありません", + noMatchesDescription: + "ワークスペースを表示するにはラベルフィルターを変更またはクリアしてください。", + }, + manage: { + open: "ラベルを管理…", + title: "ラベルを管理", + search: "ラベルを検索", + empty: "このホストにラベルはありません。", + edit: "ラベルを編集", + editLabel: "{{name}}を編集", + name: "名前", + color: "色", + save: "保存", + delete: "削除", + deleteTitle: "{{name}}を削除しますか?", + deleteMessage_one: "このホストの{{count}}個のワークスペースからラベルを削除します。", + deleteMessage_other: "このホストの{{count}}個のワークスペースからラベルを削除します。", + offline: "このホストはオフラインです。", + updateHost: "ラベルを管理するにはホストを更新してください。", + }, + }, sidebar: { display: { trigger: "表示設定", @@ -979,6 +1024,7 @@ export const ja: TranslationResources = { label: "グループ化", project: "プロジェクト", status: "ステータス", + labels: "ラベル", }, titleSource: { label: "タイトル", @@ -993,6 +1039,7 @@ export const ja: TranslationResources = { changeRequest: "プルリクエスト", checks: "チェック", services: "サービス", + labels: "ラベル", diff: "差分統計", timestamp: "最終アクティビティ", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 7305ef42d..fa8d20849 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -967,6 +967,50 @@ export const ko: TranslationResources = { }, }, }, + workspaceLabels: { + title: "레이블", + unlabelled: "레이블 없음", + create: "레이블 만들기", + createConfirm: "만들기", + creating: "만드는 중…", + name: "레이블 이름", + updateHostUse: "레이블을 사용하려면 호스트를 업데이트하세요.", + errors: { update: "레이블을 업데이트할 수 없습니다", load: "레이블을 불러올 수 없습니다" }, + colors: { + violet: "보라", + sky: "하늘", + emerald: "에메랄드", + orange: "주황", + pink: "분홍", + indigo: "남색", + teal: "청록", + red: "빨강", + amber: "호박", + blue: "파랑", + }, + filter: { + clear: "필터 지우기", + noMatchesTitle: "일치하는 워크스페이스가 없습니다", + noMatchesDescription: "워크스페이스를 보려면 레이블 필터를 변경하거나 지우세요.", + }, + manage: { + open: "레이블 관리…", + title: "레이블 관리", + search: "레이블 검색", + empty: "이 호스트에 레이블이 없습니다.", + edit: "레이블 편집", + editLabel: "{{name}} 편집", + name: "이름", + color: "색상", + save: "저장", + delete: "삭제", + deleteTitle: "{{name}}을(를) 삭제할까요?", + deleteMessage_one: "이 호스트의 워크스페이스 {{count}}개에서 레이블을 제거합니다.", + deleteMessage_other: "이 호스트의 워크스페이스 {{count}}개에서 레이블을 제거합니다.", + offline: "이 호스트는 오프라인입니다.", + updateHost: "레이블을 관리하려면 호스트를 업데이트하세요.", + }, + }, sidebar: { display: { trigger: "표시 설정", @@ -975,6 +1019,7 @@ export const ko: TranslationResources = { label: "그룹화", project: "프로젝트", status: "상태", + labels: "레이블", }, titleSource: { label: "제목", @@ -989,6 +1034,7 @@ export const ko: TranslationResources = { changeRequest: "풀 리퀘스트", checks: "검사", services: "서비스", + labels: "레이블", diff: "Diff 통계", timestamp: "마지막 활동", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index eae8e3d6c..79e2a9401 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -982,6 +982,53 @@ export const ptBR: TranslationResources = { }, }, }, + workspaceLabels: { + title: "Etiquetas", + unlabelled: "Sem etiqueta", + create: "Criar etiqueta", + createConfirm: "Criar", + creating: "Criando…", + name: "Nome da etiqueta", + updateHostUse: "Atualize este host para usar etiquetas.", + errors: { + update: "Não foi possível atualizar a etiqueta", + load: "Não foi possível carregar as etiquetas", + }, + colors: { + violet: "Violeta", + sky: "Azul-céu", + emerald: "Esmeralda", + orange: "Laranja", + pink: "Rosa", + indigo: "Índigo", + teal: "Verde-azulado", + red: "Vermelho", + amber: "Âmbar", + blue: "Azul", + }, + filter: { + clear: "Limpar filtro", + noMatchesTitle: "Nenhum espaço de trabalho corresponde", + noMatchesDescription: "Altere ou limpe o filtro de etiquetas para ver espaços de trabalho.", + }, + manage: { + open: "Gerenciar etiquetas…", + title: "Gerenciar etiquetas", + search: "Buscar etiquetas", + empty: "Não há etiquetas neste host.", + edit: "Editar etiqueta", + editLabel: "Editar {{name}}", + name: "Nome", + color: "Cor", + save: "Salvar", + delete: "Excluir", + deleteTitle: "Excluir {{name}}?", + deleteMessage_one: "Isso remove a etiqueta de {{count}} workspace neste host.", + deleteMessage_other: "Isso remove a etiqueta de {{count}} workspaces neste host.", + offline: "Este host está offline.", + updateHost: "Atualize este host para gerenciar etiquetas.", + }, + }, sidebar: { display: { trigger: "Preferências de exibição", @@ -990,6 +1037,7 @@ export const ptBR: TranslationResources = { label: "Agrupamento", project: "Projeto", status: "Status", + labels: "Etiquetas", }, titleSource: { label: "Título", @@ -1004,6 +1052,7 @@ export const ptBR: TranslationResources = { changeRequest: "Pull request", checks: "Verificações", services: "Serviços", + labels: "Etiquetas", diff: "Estatísticas de diff", timestamp: "Última atividade", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 12e543b98..ca5a7dc80 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -982,6 +982,51 @@ export const ru: TranslationResources = { }, }, }, + workspaceLabels: { + title: "Метки", + unlabelled: "Без метки", + create: "Создать метку", + createConfirm: "Создать", + creating: "Создание…", + name: "Название метки", + updateHostUse: "Обновите этот хост, чтобы использовать метки.", + errors: { update: "Не удалось обновить метку", load: "Не удалось загрузить метки" }, + colors: { + violet: "Фиолетовый", + sky: "Небесный", + emerald: "Изумрудный", + orange: "Оранжевый", + pink: "Розовый", + indigo: "Индиго", + teal: "Бирюзовый", + red: "Красный", + amber: "Янтарный", + blue: "Синий", + }, + filter: { + clear: "Очистить фильтр", + noMatchesTitle: "Нет подходящих рабочих пространств", + noMatchesDescription: + "Измените или очистите фильтр меток, чтобы увидеть рабочие пространства.", + }, + manage: { + open: "Управление метками…", + title: "Управление метками", + search: "Поиск меток", + empty: "На этом хосте нет меток.", + edit: "Изменить метку", + editLabel: "Изменить {{name}}", + name: "Название", + color: "Цвет", + save: "Сохранить", + delete: "Удалить", + deleteTitle: "Удалить {{name}}?", + deleteMessage_one: "Метка будет удалена из {{count}} рабочего пространства на этом хосте.", + deleteMessage_other: "Метка будет удалена из {{count}} рабочих пространств на этом хосте.", + offline: "Этот хост не в сети.", + updateHost: "Обновите этот хост для управления метками.", + }, + }, sidebar: { display: { trigger: "Настройки отображения", @@ -990,6 +1035,7 @@ export const ru: TranslationResources = { label: "Группировка", project: "Проект", status: "Статус", + labels: "Метки", }, titleSource: { label: "Заголовок", @@ -1004,6 +1050,7 @@ export const ru: TranslationResources = { changeRequest: "Pull request", checks: "Проверки", services: "Сервисы", + labels: "Метки", diff: "Статистика изменений", timestamp: "Последняя активность", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 86412a29f..120cfcd25 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -952,6 +952,50 @@ export const zhCN: TranslationResources = { }, }, }, + workspaceLabels: { + title: "标签", + unlabelled: "无标签", + create: "新建标签", + createConfirm: "创建", + creating: "创建中…", + name: "标签名称", + updateHostUse: "请更新此主机以使用标签。", + errors: { update: "无法更新标签", load: "无法加载标签" }, + colors: { + violet: "紫色", + sky: "天蓝色", + emerald: "翠绿色", + orange: "橙色", + pink: "粉色", + indigo: "靛蓝色", + teal: "青绿色", + red: "红色", + amber: "琥珀色", + blue: "蓝色", + }, + filter: { + clear: "清除筛选", + noMatchesTitle: "没有匹配的工作区", + noMatchesDescription: "更改或清除标签筛选以查看工作区。", + }, + manage: { + open: "管理标签…", + title: "管理标签", + search: "搜索标签", + empty: "此主机没有标签。", + edit: "编辑标签", + editLabel: "编辑{{name}}", + name: "名称", + color: "颜色", + save: "保存", + delete: "删除", + deleteTitle: "删除{{name}}?", + deleteMessage_one: "这会从此主机的{{count}}个工作区中移除该标签。", + deleteMessage_other: "这会从此主机的{{count}}个工作区中移除该标签。", + offline: "此主机离线。", + updateHost: "请更新此主机以管理标签。", + }, + }, sidebar: { display: { trigger: "显示偏好", @@ -960,6 +1004,7 @@ export const zhCN: TranslationResources = { label: "分组", project: "项目", status: "状态", + labels: "标签", }, titleSource: { label: "标题", @@ -974,6 +1019,7 @@ export const zhCN: TranslationResources = { changeRequest: "拉取请求", checks: "检查", services: "服务", + labels: "标签", diff: "差异统计", timestamp: "最近活动", }, diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index ac80a81bd..096d4a60c 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -30,6 +30,7 @@ import { type DirectorySourceToken, type DirectoryTransaction, } from "./transaction"; +import { workspaceLabels } from "@/workspace-labels"; const PAGE_LIMIT = 200; const AGENT_SORT: NonNullable = [ @@ -141,6 +142,7 @@ export class DirectorySync { this.flushAbortedTransactions(); this.unsubscribe?.(); this.unsubscribe = null; + workspaceLabels.disconnect(this.serverId); this.connection = connection; this.abortPendingSessionWaits(); if (!connection.client || connection.status !== "online") return true; @@ -197,6 +199,7 @@ export class DirectorySync { this.abortPendingSessionWaits(); this.unsubscribe?.(); this.unsubscribe = null; + workspaceLabels.disconnect(this.serverId); } async fetchTimeline( @@ -369,6 +372,17 @@ export class DirectorySync { async refreshAll(): Promise { await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]); + await this.connectWorkspaceLabels(); + } + + async connectWorkspaceLabels(): Promise { + const { client } = this.requireOnline(); + const serverInfo = client.getLastServerInfoMessage(); + await workspaceLabels.connect({ + serverId: this.serverId, + client, + supportsWorkspaceLabels: serverInfo?.features?.workspaceLabels === true, + }); } private async fetchAgents( diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 86ee71c73..4d27f200c 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -2075,8 +2075,9 @@ export class HostRuntimeStore { return; } const snapshot = controller.getSnapshot(); + const directory = this.directorySyncByServer.get(serverId); const directorySourceChanged = - this.directorySyncByServer.get(serverId)?.connectionChanged({ + directory?.connectionChanged({ client: snapshot.client, status: snapshot.connectionStatus === "online" ? "online" : "offline", source: { @@ -2137,7 +2138,14 @@ export class HostRuntimeStore { error: toErrorMessage(error), }); }), - ]).then(() => undefined), + ]).then(() => + directory?.connectWorkspaceLabels().catch((error) => { + console.error("[HostRuntime] workspace label bootstrap failed", { + serverId, + error: toErrorMessage(error), + }); + }), + ), ) .finally(() => { const inFlight = this.directoryBootstrapInFlight.get(serverId); diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 2b85b0149..3d27070fa 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -149,6 +149,7 @@ function seedSession(): void { ...workspace(), workspaceKind: "worktree", worktreeSlug: "owned-worktree", + labels: ["backend"], }), ], ]), @@ -261,6 +262,9 @@ describe("ReplicaCache", () => { expect(session.agents.get("agent-1")?.projectPlacement?.checkout.cwd).toBe("/repo/paseo"); expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree"); + // A restored row draws its label chips. The reconnect cursor is current, so nothing re-sends + // them and a cache that dropped them would leave the sidebar unlabelled until the next edit. + expect(session.workspaces.get("workspace-1")?.labels).toEqual(["backend"]); expect(session.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); expect(session.agentAuthoritativeHistoryApplied).toEqual(new Map([["agent-1", true]])); expect(session.agentTimelineCursor).toEqual( diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index ef075d85f..ddbaef848 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -235,6 +235,10 @@ const StoredWorkspaceSchema = z.strictObject({ name: z.string(), title: z.string().nullable(), pinnedAt: z.string().nullable(), + // Optional because entries written before labels existed have none. A cached workspace that + // dropped them painted its row without its chips and stayed that way: the directory cursor is + // current on reconnect, so the daemon has nothing newer to send back. + labels: z.array(z.string()).optional(), status: z.enum(["needs_input", "failed", "running", "attention", "done"]), statusEnteredAt: IsoDateSchema.nullable(), activityAt: z.null(), @@ -559,6 +563,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { name: workspace.name, title: workspace.title ?? null, pinnedAt: workspace.pinnedAt ?? null, + labels: workspace.labels, status: workspace.status, statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null, activityAt: null, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index aeed4991a..fdae0941e 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -118,6 +118,7 @@ export interface WorkspaceDescriptor { name: string; title?: string | null; pinnedAt?: string | null; + labels?: string[]; status: WorkspaceDescriptorPayload["status"]; statusEnteredAt: Date | null; archivingAt: string | null; @@ -154,6 +155,8 @@ export function normalizeWorkspaceDescriptor( name: payload.name, title: payload.title ?? null, pinnedAt: payload.pinnedAt ?? null, + // COMPAT(workspaceLabels): old daemons omit assignments. + labels: payload.labels ?? [], status: payload.status, statusEnteredAt, archivingAt: payload.archivingAt ?? null, diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts index c4fb6239a..bd24bed87 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts @@ -11,13 +11,13 @@ import { setProjectCollapsed, togglePinnedCollapsed, toggleProjectCollapsed, - toggleStatusGroupCollapsed, + toggleWorkspaceGroupCollapsed, } from "./state"; interface SidebarCollapsedSectionsState extends CollapsedProjectsState { toggleProjectCollapsed: (projectKey: string) => void; setProjectCollapsed: (projectKey: string, collapsed: boolean) => void; - toggleStatusGroupCollapsed: (statusGroupKey: string) => void; + toggleWorkspaceGroupCollapsed: (workspaceGroupKey: string) => void; togglePinnedCollapsed: () => void; } @@ -25,14 +25,14 @@ export const useSidebarCollapsedSectionsStore = create( (set) => ({ collapsedProjectKeys: new Set(), - collapsedStatusGroupKeys: new Set(), + collapsedWorkspaceGroupKeys: new Set(), collapsedPinned: false, toggleProjectCollapsed: (projectKey) => set((state) => toggleProjectCollapsed(state, projectKey)), setProjectCollapsed: (projectKey, collapsed) => set((state) => setProjectCollapsed(state, projectKey, collapsed)), - toggleStatusGroupCollapsed: (statusGroupKey) => - set((state) => toggleStatusGroupCollapsed(state, statusGroupKey)), + toggleWorkspaceGroupCollapsed: (workspaceGroupKey) => + set((state) => toggleWorkspaceGroupCollapsed(state, workspaceGroupKey)), togglePinnedCollapsed: () => set((state) => togglePinnedCollapsed(state)), }), { diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts index f2d9258b6..6d88dff7c 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts @@ -6,13 +6,13 @@ import { setProjectCollapsed, togglePinnedCollapsed, toggleProjectCollapsed, - toggleStatusGroupCollapsed, + toggleWorkspaceGroupCollapsed, } from "@/stores/sidebar-collapsed-sections-store/state"; function emptyState(): CollapsedProjectsState { return { collapsedProjectKeys: new Set(), - collapsedStatusGroupKeys: new Set(), + collapsedWorkspaceGroupKeys: new Set(), collapsedPinned: false, }; } @@ -24,22 +24,22 @@ describe("sidebar collapsed projects transitions", () => { state = setProjectCollapsed(state, "project-a", true); state = toggleProjectCollapsed(state, "project-b"); state = toggleProjectCollapsed(state, "project-a"); - state = toggleStatusGroupCollapsed(state, "running"); + state = toggleWorkspaceGroupCollapsed(state, "running"); expect(Array.from(state.collapsedProjectKeys)).toEqual(["project-b"]); - expect(Array.from(state.collapsedStatusGroupKeys)).toEqual(["running"]); + expect(Array.from(state.collapsedWorkspaceGroupKeys)).toEqual(["running"]); }); it("serializes collapsed project keys for preference storage", () => { const state: CollapsedProjectsState = { collapsedProjectKeys: new Set(["project-a", "project-b"]), - collapsedStatusGroupKeys: new Set(["running"]), + collapsedWorkspaceGroupKeys: new Set(["running"]), collapsedPinned: true, }; expect(serializeCollapsedProjects(state)).toEqual({ collapsedProjectKeys: ["project-a", "project-b"], - collapsedStatusGroupKeys: ["running"], + collapsedWorkspaceGroupKeys: ["running"], collapsedPinned: true, }); }); @@ -59,7 +59,7 @@ describe("sidebar collapsed projects transitions", () => { ); expect(Array.from(restored.collapsedProjectKeys)).toEqual([]); - expect(Array.from(restored.collapsedStatusGroupKeys)).toEqual([]); + expect(Array.from(restored.collapsedWorkspaceGroupKeys)).toEqual([]); }); it("keeps the existing state object when persisted preferences do not change collapsed keys", () => { diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts index 4d84205e1..cf92e600b 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts @@ -2,12 +2,13 @@ import { z } from "zod"; export interface CollapsedProjectsState { collapsedProjectKeys: Set; - collapsedStatusGroupKeys: Set; + collapsedWorkspaceGroupKeys: Set; collapsedPinned: boolean; } export interface PersistedCollapsedProjects { collapsedProjectKeys?: string[]; + collapsedWorkspaceGroupKeys?: string[]; collapsedStatusGroupKeys?: string[]; collapsedPinned?: boolean; } @@ -15,6 +16,8 @@ export interface PersistedCollapsedProjects { export const PersistedCollapsedProjectsSchema: z.ZodType = z.strictObject({ collapsedProjectKeys: z.array(z.string()).optional(), + collapsedWorkspaceGroupKeys: z.array(z.string()).optional(), + // COMPAT(sidebarWorkspaceGroupCollapse): added in v0.4.0, remove after 2027-02-14. collapsedStatusGroupKeys: z.array(z.string()).optional(), collapsedPinned: z.boolean().optional(), }); @@ -36,17 +39,17 @@ export function toggleProjectCollapsed( return { ...state, collapsedProjectKeys: next }; } -export function toggleStatusGroupCollapsed( +export function toggleWorkspaceGroupCollapsed( state: CollapsedProjectsState, - statusGroupKey: string, + workspaceGroupKey: string, ): CollapsedProjectsState { - const next = new Set(state.collapsedStatusGroupKeys); - if (next.has(statusGroupKey)) { - next.delete(statusGroupKey); + const next = new Set(state.collapsedWorkspaceGroupKeys); + if (next.has(workspaceGroupKey)) { + next.delete(workspaceGroupKey); } else { - next.add(statusGroupKey); + next.add(workspaceGroupKey); } - return { ...state, collapsedStatusGroupKeys: next }; + return { ...state, collapsedWorkspaceGroupKeys: next }; } export function setProjectCollapsed( @@ -65,12 +68,12 @@ export function setProjectCollapsed( export function serializeCollapsedProjects(state: CollapsedProjectsState): { collapsedProjectKeys: string[]; - collapsedStatusGroupKeys: string[]; + collapsedWorkspaceGroupKeys: string[]; collapsedPinned: boolean; } { return { collapsedProjectKeys: Array.from(state.collapsedProjectKeys), - collapsedStatusGroupKeys: Array.from(state.collapsedStatusGroupKeys), + collapsedWorkspaceGroupKeys: Array.from(state.collapsedWorkspaceGroupKeys), collapsedPinned: state.collapsedPinned, }; } @@ -87,13 +90,15 @@ export function mergePersistedCollapsedProjects { useSidebarViewStore.setState({ groupMode: "project", hostFilters: [], + labelFilter: { labels: [] }, }); }); @@ -90,6 +93,7 @@ describe("sidebar view store", () => { ).toEqual({ groupMode: "status", hostFilters: [], + labelFilter: { labels: [] }, }); }); @@ -102,6 +106,7 @@ describe("sidebar view store", () => { ).toEqual({ groupMode: "status", hostFilters: ["host-a"], + labelFilter: { labels: [] }, }); }); @@ -114,9 +119,69 @@ describe("sidebar view store", () => { ).toEqual({ groupMode: "status", hostFilters: ["host-a", "host-b"], + labelFilter: { labels: [] }, }); }); + it("clears only the label facet", () => { + useSidebarViewStore.setState({ + groupMode: "status", + hostFilters: ["host-a"], + labelFilter: { labels: ["urgent", "blocked"] }, + }); + + useSidebarViewStore.getState().clearLabelFilter(); + + expect(useSidebarViewStore.getState()).toMatchObject({ + groupMode: "status", + hostFilters: ["host-a"], + labelFilter: { labels: [] }, + }); + }); + + it("toggles a label on and off under one normalized identity", () => { + const { toggleLabelFilter } = useSidebarViewStore.getState(); + const labels = () => useSidebarViewStore.getState().labelFilter.labels; + + toggleLabelFilter("Urgent"); + expect(labels()).toEqual(["urgent"]); + expect(hasActiveSidebarLabelFilter(useSidebarViewStore.getState().labelFilter)).toBe(true); + + toggleLabelFilter(" URGENT "); + expect(labels()).toEqual([]); + expect(hasActiveSidebarLabelFilter(useSidebarViewStore.getState().labelFilter)).toBe(false); + }); + + it("filters Unlabelled alongside real labels without colliding with one", () => { + const { toggleLabelFilter } = useSidebarViewStore.getState(); + + toggleLabelFilter(SIDEBAR_UNLABELLED_LABEL_KEY); + toggleLabelFilter("Urgent"); + expect(useSidebarViewStore.getState().labelFilter).toEqual({ + labels: [SIDEBAR_UNLABELLED_LABEL_KEY, "urgent"], + }); + }); + + it("drops deleted labels from the active filter but retains Unlabelled", () => { + useSidebarViewStore.setState({ + labelFilter: { labels: [SIDEBAR_UNLABELLED_LABEL_KEY, "urgent", "removed"] }, + }); + + useSidebarViewStore.getState().reconcileLabelFilter(["Urgent"]); + + expect(useSidebarViewStore.getState().labelFilter).toEqual({ + labels: [SIDEBAR_UNLABELLED_LABEL_KEY, "urgent"], + }); + }); + + it("re-keys a persisted label filter through the normalized identity, without duplicates", () => { + expect( + migrateSidebarViewState({ + labelFilter: { labels: [" Urgent ", "BLOCKED", "urgent"], match: "all" }, + }).labelFilter, + ).toEqual({ labels: ["urgent", "blocked"] }); + }); + it("falls back to the legacy storage key when the new key is empty", async () => { const storage = createMemoryStorage({ "sidebar-view": null, diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index e8d77b0a0..9c8f35f69 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -2,35 +2,68 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { persist, type StateStorage } from "zustand/middleware"; import { z } from "zod"; +import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type SidebarGroupMode = "project" | "status"; const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view"; const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode"; -const SIDEBAR_VIEW_STORE_VERSION = 2; +const SIDEBAR_VIEW_STORE_VERSION = 5; + +/** + * The key standing for "this workspace carries no labels at all". + * + * `normalizeWorkspaceLabelName` trims, so no real label can ever normalize to the empty string + * and nothing in `labels` can collide with it. That is the whole reason the empty string is the + * choice: a sentinel like `"__unlabelled__"` would be a name a person is free to type. + */ +export const SIDEBAR_UNLABELLED_LABEL_KEY = ""; + +/** + * What the sidebar's Labels page currently says. + * + * The labels pinned to, keyed by `workspaceLabelKey`, exactly as `hostFilters` holds server ids: + * empty means "every label", non-empty includes workspaces carrying any selected label. + */ +export interface SidebarLabelFilter { + labels: string[]; +} + +export function hasActiveSidebarLabelFilter(filter: SidebarLabelFilter): boolean { + return filter.labels.length > 0; +} interface SidebarViewStoreState { groupMode: SidebarGroupMode; // Empty means "all hosts". A non-empty list pins the sidebar to those hosts. hostFilters: string[]; + labelFilter: SidebarLabelFilter; setGroupMode: (mode: SidebarGroupMode) => void; toggleHostFilter: (serverId: string) => void; clearHostFilters: () => void; + toggleLabelFilter: (name: string) => void; + clearLabelFilter: () => void; + reconcileLabelFilter: (labels: readonly string[]) => void; reconcileHostFilters: (serverIds: readonly string[]) => void; } interface SidebarViewPersistedState { groupMode: SidebarGroupMode; hostFilters: string[]; + labelFilter: SidebarLabelFilter; } -const SidebarGroupModeSchema = z.enum(["project", "status"]); +const PersistedSidebarGroupModeSchema = z.enum(["project", "status", "label"]); +const SidebarLabelFilterSchema = z.object({ + labels: z.array(z.string()), +}); const SidebarViewPersistedStateSchema = z.strictObject({ - groupMode: SidebarGroupModeSchema.optional(), + groupMode: PersistedSidebarGroupModeSchema.optional(), hostFilters: z.array(z.string()).optional(), hostFilter: z.string().nullable().optional(), - groupModeByServerId: z.record(z.string(), SidebarGroupModeSchema).optional(), + groupModeByServerId: z.record(z.string(), PersistedSidebarGroupModeSchema).optional(), + labelFilter: SidebarLabelFilterSchema.optional(), }); type SidebarViewStorageState = z.infer; @@ -62,21 +95,33 @@ function readHostFilters(persistedState: SidebarViewStorageState): string[] { export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState { const result = SidebarViewPersistedStateSchema.safeParse(persistedState); if (!result.success) { - return { groupMode: "project", hostFilters: [] }; + return { groupMode: "project", hostFilters: [], labelFilter: emptyLabelFilter() }; } const state = result.data; const legacyGroupMode = readLegacyGroupMode(state); if (legacyGroupMode) { - return { groupMode: legacyGroupMode, hostFilters: [] }; + return { groupMode: legacyGroupMode, hostFilters: [], labelFilter: emptyLabelFilter() }; } return { - groupMode: state.groupMode ?? "project", + groupMode: state.groupMode === "status" ? "status" : "project", hostFilters: readHostFilters(state), + labelFilter: state.labelFilter + ? normalizeSidebarLabelFilter(state.labelFilter) + : emptyLabelFilter(), }; } +/** + * Re-keys a persisted filter through `workspaceLabelKey`, so the invariant on `labels` holds for + * state that was written by an older build of this page rather than by the current one. + */ +function normalizeSidebarLabelFilter(filter: SidebarLabelFilter): SidebarLabelFilter { + const labels = new Set(filter.labels.map(workspaceLabelKey)); + return { labels: [...labels] }; +} + export function createSidebarViewStorage( backingStorage: StateStorage = AsyncStorage, ): StateStorage { @@ -98,6 +143,7 @@ export const useSidebarViewStore = create()( (set) => ({ groupMode: "project", hostFilters: [], + labelFilter: emptyLabelFilter(), setGroupMode: (mode) => set({ groupMode: mode }), toggleHostFilter: (serverId) => set((state) => ({ @@ -106,6 +152,24 @@ export const useSidebarViewStore = create()( : [...state.hostFilters, serverId], })), clearHostFilters: () => set({ hostFilters: [] }), + toggleLabelFilter: (name) => + set((state) => { + const key = workspaceLabelKey(name); + const labels = state.labelFilter.labels.includes(key) + ? state.labelFilter.labels.filter((label) => label !== key) + : [...state.labelFilter.labels, key]; + return { labelFilter: { ...state.labelFilter, labels } }; + }), + clearLabelFilter: () => set({ labelFilter: emptyLabelFilter() }), + reconcileLabelFilter: (labels) => + set((state) => { + const available = new Set(labels.map(workspaceLabelKey)); + const next = state.labelFilter.labels.filter( + (label) => label === SIDEBAR_UNLABELLED_LABEL_KEY || available.has(label), + ); + if (next.length === state.labelFilter.labels.length) return state; + return { labelFilter: { labels: next } }; + }), reconcileHostFilters: (serverIds) => set((state) => { if (state.hostFilters.length === 0) { @@ -129,8 +193,13 @@ export const useSidebarViewStore = create()( partialize: (state) => ({ groupMode: state.groupMode, hostFilters: state.hostFilters, + labelFilter: state.labelFilter, }), migrate: migrateSidebarViewState, }, ), ); + +function emptyLabelFilter(): SidebarLabelFilter { + return { labels: [] }; +} diff --git a/packages/app/src/workspace-labels/chip.tsx b/packages/app/src/workspace-labels/chip.tsx new file mode 100644 index 000000000..1d8274352 --- /dev/null +++ b/packages/app/src/workspace-labels/chip.tsx @@ -0,0 +1,108 @@ +import type { ReactElement } from "react"; +import { Text, View, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { + WORKSPACE_LABEL_COLORS, + type WorkspaceLabelColor, + type WorkspaceLabelDefinition, +} from "@getpaseo/protocol/workspace-labels"; +import { identityForeground, identityTint } from "@/styles/identity-colors"; +import { SPACING } from "@/styles/theme"; + +/** + * How far the ground extends past the name on each side. + * + * Exported because it is the chip's optical offset as much as its padding: a caller that puts a + * chip at the head of a line has to hang the ground by this much to land the name on the rail. + * A static number from the spacing scale, not a theme read — see docs/unistyles.md. + */ +export const WORKSPACE_LABEL_CHIP_INSET = SPACING[1.5]; + +/** + * A label where it is read rather than managed: its name in its own color, on a 10% ground of + * the same hue — the tint formula the status pills already use. + * + * It is the one tinted thing on the workspace meta row, and that is the point. Every other item + * on that line reports state, so they share the state colors; a label is something a person + * attached to a workspace, and the ground is what separates that from a passing check sitting + * next to it. The color comes from the identity table, held to the same contrast band as the + * host badge beside it, so a chip identifies without shouting. + * + * The chip is exactly as tall as the line it sits on: text at the row's `lineHeight`, no vertical + * padding. A ground that grew the line would push every other row in the sidebar down for it. + */ +export function WorkspaceLabelChip({ label }: { label: WorkspaceLabelDefinition }): ReactElement { + return ( + + + {label.name} + + + ); +} + +// `identityTint` is theme-independent — one hex per name at 10% alpha — so unlike the text color +// these can be built once at module load. There is no Unistyles proxy here to freeze against +// whichever theme happened to be active first. +const CHIP_GROUNDS: Record = Object.fromEntries( + WORKSPACE_LABEL_COLORS.map((color) => [color, { backgroundColor: identityTint(color) }]), +) as Record; + +const styles = StyleSheet.create((theme) => ({ + chip: { + // Yields space the way the host badge does, so a long label truncates instead of pushing + // the change request and its checks off the line. + flexShrink: 1, + minWidth: 0, + paddingHorizontal: WORKSPACE_LABEL_CHIP_INSET, + borderRadius: theme.borderRadius.full, + }, + // No optical nudge: the ground is the text's own line box, so the chip's ink sits on the same + // baseline as the bare text beside it and centring is whatever the line already does. + name: { + fontSize: theme.fontSize.sm, + lineHeight: 16, + flexShrink: 1, + minWidth: 0, + }, + // Text has no `color` prop to hand a mapping to, so the name's color has to come from a + // registered style — one per color, picked at render time. See docs/unistyles.md. + nameViolet: { color: identityForeground("violet", theme.colorScheme) }, + nameSky: { color: identityForeground("sky", theme.colorScheme) }, + nameEmerald: { color: identityForeground("emerald", theme.colorScheme) }, + nameOrange: { color: identityForeground("orange", theme.colorScheme) }, + namePink: { color: identityForeground("pink", theme.colorScheme) }, + nameIndigo: { color: identityForeground("indigo", theme.colorScheme) }, + nameTeal: { color: identityForeground("teal", theme.colorScheme) }, + nameRed: { color: identityForeground("red", theme.colorScheme) }, + nameAmber: { color: identityForeground("amber", theme.colorScheme) }, + nameBlue: { color: identityForeground("blue", theme.colorScheme) }, +})); + +function nameColorStyle(color: WorkspaceLabelColor) { + switch (color) { + case "violet": + return styles.nameViolet; + case "sky": + return styles.nameSky; + case "emerald": + return styles.nameEmerald; + case "orange": + return styles.nameOrange; + case "pink": + return styles.namePink; + case "indigo": + return styles.nameIndigo; + case "teal": + return styles.nameTeal; + case "red": + return styles.nameRed; + case "amber": + return styles.nameAmber; + case "blue": + return styles.nameBlue; + } +} diff --git a/packages/app/src/workspace-labels/index.test.ts b/packages/app/src/workspace-labels/index.test.ts new file mode 100644 index 000000000..dacbc6b4f --- /dev/null +++ b/packages/app/src/workspace-labels/index.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, test } from "vitest"; +import { + buildWorkspaceLabelPickerRows, + createWorkspaceLabelManagerModel, + createWorkspaceLabelPickerModel, + hasAuthoritativeWorkspaceLabelCatalog, + mergeWorkspaceLabelCatalogs, + projectWorkspaceLabels, + selectWorkspaceLabelDefinitions, + type WorkspaceLabelHostSnapshot, +} from "./index"; +// A label's color is no longer a table of its own: `swatch.tsx` hands every surface the +// identity color of the same name, and `identityForeground` typechecking against +// `WorkspaceLabelColor` is what keeps the protocol's ten names and the identity table's ten in +// step. There is nothing left here to assert that the compiler does not. + +describe("workspace label projection", () => { + test("does not treat a failed online catalog as authoritative", () => { + expect( + hasAuthoritativeWorkspaceLabelCatalog([ + { serverId: "host-a", status: "online", error: "request failed", labels: [] }, + ]), + ).toBe(false); + expect( + hasAuthoritativeWorkspaceLabelCatalog([ + { + serverId: "host-a", + status: "online", + error: null, + labels: [{ name: "Urgent", color: "red" }], + }, + ]), + ).toBe(true); + }); + + test("merges normalized names deterministically and lets the target host definition win", () => { + const catalogs = [ + { serverId: "host-b", labels: [{ name: "Blocked", color: "red" as const }] }, + { serverId: "host-a", labels: [{ name: "blocked", color: "sky" as const }] }, + ]; + expect(mergeWorkspaceLabelCatalogs({ catalogs })).toEqual([{ name: "blocked", color: "sky" }]); + expect(mergeWorkspaceLabelCatalogs({ catalogs, targetServerId: "host-b" })).toEqual([ + { name: "Blocked", color: "red" }, + ]); + }); + + test("owns online filtering and target precedence in one public projection", () => { + const projection = projectWorkspaceLabels( + { + online: { + serverId: "online", + status: "online", + error: null, + labels: [{ name: "Urgent", color: "red" }], + }, + offline: { + serverId: "offline", + status: "offline", + error: null, + labels: [ + { name: "urgent", color: "sky" }, + { name: "Hidden", color: "emerald" }, + ], + }, + }, + "offline", + ); + + expect(projection.labels).toEqual([{ name: "Urgent", color: "red" }]); + expect(projection.targetHost?.status).toBe("offline"); + }); + + test("draws a shared label name in the workspace's own host color", () => { + // Two hosts, one name, two colors. The cross-host merge would settle this by target + // precedence and then alphabetical serverId, so a row asking without saying whose row it is + // can end up painted in the other host's color. + const hostsById: Record = { + "host-a": { + serverId: "host-a", + status: "online", + error: null, + labels: [{ name: "backend", color: "violet" }], + }, + "host-b": { + serverId: "host-b", + status: "online", + error: null, + labels: [{ name: "Backend", color: "red" }], + }, + }; + + expect( + selectWorkspaceLabelDefinitions({ hostsById, serverId: "host-b", names: ["Backend"] }), + ).toEqual([{ name: "Backend", color: "red" }]); + expect( + selectWorkspaceLabelDefinitions({ hostsById, serverId: "host-a", names: ["backend"] }), + ).toEqual([{ name: "backend", color: "violet" }]); + }); + + test("keeps the assignment order and drops names the workspace's own host does not know", () => { + const hostsById: Record = { + "host-a": { + serverId: "host-a", + status: "online", + error: null, + labels: [{ name: "Urgent", color: "red" }], + }, + "host-b": { + serverId: "host-b", + status: "online", + error: null, + labels: [{ name: "Backend", color: "sky" }], + }, + }; + + expect( + selectWorkspaceLabelDefinitions({ + hostsById, + serverId: "host-a", + names: ["urgent", "backend"], + }), + ).toEqual([{ name: "Urgent", color: "red" }]); + expect( + selectWorkspaceLabelDefinitions({ hostsById, serverId: "unknown", names: ["Urgent"] }), + ).toEqual([]); + }); + + test("ticks a row against the assignment recorded in another case", () => { + expect( + buildWorkspaceLabelPickerRows({ + labels: [ + { name: "Blocked", color: "red" }, + { name: "Needs review", color: "sky" }, + ], + assigned: ["blocked"], + }), + ).toEqual([ + { name: "Blocked", color: "red", assigned: true }, + { name: "Needs review", color: "sky", assigned: false }, + ]); + }); + + test("picker holds one mutation per label and keeps the failure on the page", async () => { + let resolveMutation!: () => void; + let mutationCount = 0; + let fail = false; + const model = createWorkspaceLabelPickerModel({ + mutate: async () => { + mutationCount += 1; + await new Promise((resolve) => { + resolveMutation = resolve; + }); + if (fail) throw new Error("disk full"); + }, + }); + model.sync({ + labels: [{ name: "Urgent", color: "red" }], + assigned: [], + online: true, + }); + + // A double tap is one mutation, not an add racing a remove. + const first = model.toggle({ name: "Urgent", color: "red" }, true); + const repeated = model.toggle({ name: "Urgent", color: "red" }, false); + expect(model.snapshot().pendingNames).toEqual(["urgent"]); + expect(mutationCount).toBe(1); + resolveMutation(); + await Promise.all([first, repeated]); + expect(model.snapshot()).toMatchObject({ pendingNames: [], error: null }); + + fail = true; + const failed = model.toggle({ name: "Urgent", color: "red" }, true); + resolveMutation(); + expect(await failed).toBe(false); + expect(model.snapshot()).toMatchObject({ + error: "disk full", + pendingNames: [], + rows: [{ name: "Urgent", color: "red", assigned: false }], + }); + }); + + test("manager sends one update for both fields and closes the edit only once it lands", async () => { + const updates: unknown[] = []; + const model = createWorkspaceLabelManagerModel({ + update: async (input) => { + updates.push(input); + return { label: { name: "Priority", color: "sky" } }; + }, + inspectDelete: async () => ({ affectedWorkspaceCount: 0 }), + delete: async () => undefined, + }); + model.syncHosts([ + { + serverId: "host-a", + label: "Alpha", + status: "online", + labels: [{ name: "Urgent", color: "red" }], + }, + ]); + + model.edit({ name: "Urgent", color: "red" }); + expect(model.snapshot()).toMatchObject({ + draft: { name: "Urgent", draftName: "Urgent", color: "red" }, + dirty: false, + }); + + model.setDraftName("Priority"); + model.setDraftColor("sky"); + expect(model.snapshot().dirty).toBe(true); + + await model.save(); + // One call carrying both fields — a rename that lands and a recolour that does not is a + // state the user never asked for. + expect(updates).toEqual([ + { serverId: "host-a", name: "Urgent", newName: "Priority", color: "sky" }, + ]); + expect(model.snapshot()).toMatchObject({ draft: null, error: null }); + }); + + test("manager sends only the field that changed and skips an untouched draft", async () => { + const updates: unknown[] = []; + const model = createWorkspaceLabelManagerModel({ + update: async (input) => { + updates.push(input); + return {}; + }, + inspectDelete: async () => ({ affectedWorkspaceCount: 0 }), + delete: async () => undefined, + }); + model.syncHosts([ + { + serverId: "host-a", + label: "Alpha", + status: "online", + labels: [{ name: "Urgent", color: "red" }], + }, + ]); + + model.edit({ name: "Urgent", color: "red" }); + model.setDraftColor("amber"); + await model.save(); + expect(updates).toEqual([{ serverId: "host-a", name: "Urgent", color: "amber" }]); + + model.syncHosts([ + { + serverId: "host-a", + label: "Alpha", + status: "online", + labels: [{ name: "Urgent", color: "amber" }], + }, + ]); + model.edit({ name: "Urgent", color: "amber" }); + // Whitespace is not an edit: the name normalizes back to what the label already is. + model.setDraftName(" Urgent "); + expect(model.snapshot().dirty).toBe(false); + // Nor is an empty one — Save has nothing to offer either way. + model.setDraftName(" "); + expect(model.snapshot().dirty).toBe(false); + await model.save(); + expect(updates).toHaveLength(1); + }); + + test("manager owns host selection and keeps the draft intact on a failed update", async () => { + let deleteCalls = 0; + const model = createWorkspaceLabelManagerModel({ + update: async () => { + // The daemon client appends the correlation it failed on to every RPC error. + throw Object.assign( + new Error( + "A label with that name already exists requestType=workspace.label.update.request code=label_name_taken", + ), + { requestType: "workspace.label.update.request", code: "label_name_taken" }, + ); + }, + inspectDelete: async () => ({ affectedWorkspaceCount: 7 }), + delete: async () => { + deleteCalls += 1; + throw new Error("delete failed"); + }, + }); + model.syncHosts([ + { + serverId: "host-a", + label: "Alpha", + status: "online", + labels: [{ name: "Urgent", color: "red" }], + }, + { + serverId: "host-b", + label: "Beta", + status: "online", + labels: [{ name: "Other", color: "sky" }], + }, + ]); + model.edit({ name: "Urgent", color: "red" }); + model.setDraftName("Priority"); + model.setDraftColor("amber"); + + await model.save(); + // Nothing half-applied: the view stays open on the draft exactly as typed. + expect(model.snapshot()).toMatchObject({ + serverId: "host-a", + draft: { name: "Urgent", draftName: "Priority", color: "amber" }, + editing: { name: "Urgent", color: "red" }, + // What the daemon said, without the correlation it travelled with. + error: "A label with that name already exists", + pending: false, + }); + + let confirmedCount = 0; + const first = model.delete(async (count) => { + confirmedCount = count; + return true; + }); + const repeated = model.delete(async () => true); + await Promise.all([first, repeated]); + expect(confirmedCount).toBe(7); + expect(deleteCalls).toBe(1); + expect(model.snapshot()).toMatchObject({ + draft: { name: "Urgent" }, + error: "delete failed", + pending: false, + }); + + model.selectHost("host-b"); + expect(model.snapshot()).toMatchObject({ serverId: "host-b", draft: null }); + }); + + test("manager keeps one immutable host and label target through delete confirmation", async () => { + let confirmDelete!: (confirmed: boolean) => void; + const confirmation = new Promise((resolve) => { + confirmDelete = resolve; + }); + const inspected: string[] = []; + const deleted: string[] = []; + const model = createWorkspaceLabelManagerModel({ + update: async () => ({}), + inspectDelete: async ({ serverId, name }) => { + inspected.push(`${serverId}:${name}`); + return { affectedWorkspaceCount: 1 }; + }, + delete: async ({ serverId, name }) => { + deleted.push(`${serverId}:${name}`); + }, + }); + model.syncHosts([ + { + serverId: "host-a", + label: "Alpha", + status: "online", + labels: [{ name: "Urgent", color: "red" }], + }, + { + serverId: "host-b", + label: "Beta", + status: "online", + labels: [{ name: "Urgent", color: "sky" }], + }, + ]); + model.edit({ name: "Urgent", color: "red" }); + + const remove = model.delete(() => confirmation); + await Promise.resolve(); + expect(model.snapshot().pending).toBe(true); + model.selectHost("host-b"); + model.edit({ name: "Urgent", color: "sky" }); + confirmDelete(true); + await remove; + + expect(inspected).toEqual(["host-a:Urgent"]); + expect(deleted).toEqual(["host-a:Urgent"]); + expect(model.snapshot()).toMatchObject({ serverId: "host-a", draft: null }); + }); +}); diff --git a/packages/app/src/workspace-labels/index.ts b/packages/app/src/workspace-labels/index.ts new file mode 100644 index 000000000..b2c46ab74 --- /dev/null +++ b/packages/app/src/workspace-labels/index.ts @@ -0,0 +1,254 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { + workspaceLabelKey, + type WorkspaceLabelDefinition, +} from "@getpaseo/protocol/workspace-labels"; +import { useMemo } from "react"; +import { create } from "zustand"; +import { HostWorkspaceLabelReplica } from "./internal/host-replica"; +import { mergeWorkspaceLabelCatalogs } from "./internal/merge"; +import { WorkspaceLabelManagerModel, WorkspaceLabelPickerModel } from "./internal/workflow-model"; +import { i18n } from "@/i18n/i18next"; + +export { buildWorkspaceLabelPickerRows } from "./internal/picker-model"; +export type { WorkspaceLabelPickerRow } from "./internal/picker-model"; +export { mergeWorkspaceLabelCatalogs } from "./internal/merge"; +export { workspaceLabelErrorMessage } from "./internal/workflow-model"; +export type { + WorkspaceLabelManagerDraft, + WorkspaceLabelManagerHost, + WorkspaceLabelManagerSnapshot, + WorkspaceLabelPickerModelSnapshot, +} from "./internal/workflow-model"; + +export function createWorkspaceLabelPickerModel( + dependencies: ConstructorParameters[0], +): WorkspaceLabelPickerModel { + return new WorkspaceLabelPickerModel(dependencies); +} + +export function createWorkspaceLabelManagerModel( + dependencies: ConstructorParameters[0], +): WorkspaceLabelManagerModel { + return new WorkspaceLabelManagerModel(dependencies); +} + +export interface WorkspaceLabelHostSnapshot { + serverId: string; + labels: WorkspaceLabelDefinition[]; + status: "offline" | "online" | "unsupported"; + error: string | null; +} + +interface WorkspaceLabelState { + hosts: Record; + setHost: (host: WorkspaceLabelHostSnapshot) => void; +} + +export const useWorkspaceLabels = create((set) => ({ + hosts: {}, + setHost: (host) => set((state) => ({ hosts: { ...state.hosts, [host.serverId]: host } })), +})); + +export interface WorkspaceLabelProjection { + hosts: readonly WorkspaceLabelHostSnapshot[]; + labels: readonly WorkspaceLabelDefinition[]; + targetHost: WorkspaceLabelHostSnapshot | undefined; +} + +/** + * A connected host with a failed catalog request can still accept label mutations, but its + * replica is not evidence that a persisted filter names a deleted label. Reconciliation is + * destructive, so wait until every connected catalog has loaded successfully. + */ +export function hasAuthoritativeWorkspaceLabelCatalog( + hosts: readonly WorkspaceLabelHostSnapshot[], +): boolean { + return ( + hosts.some((host) => host.status === "online") && + !hosts.some((host) => host.status === "online" && host.error !== null) + ); +} + +export function projectWorkspaceLabels( + hostsById: Readonly>, + targetServerId?: string, +): WorkspaceLabelProjection { + const hosts = Object.values(hostsById); + const catalogs = hosts + .filter((host) => host.status === "online") + .map((host) => ({ serverId: host.serverId, labels: host.labels })); + return { + hosts, + labels: mergeWorkspaceLabelCatalogs({ catalogs, targetServerId }), + targetHost: targetServerId ? hostsById[targetServerId] : undefined, + }; +} + +/** Reactive cross-host projection. Consumers do not reconstruct replica eligibility or precedence. */ +export function useWorkspaceLabelProjection(targetServerId?: string): WorkspaceLabelProjection { + const hosts = useWorkspaceLabels((state) => state.hosts); + return useMemo(() => projectWorkspaceLabels(hosts, targetServerId), [hosts, targetServerId]); +} + +/** + * The definitions behind the label names a workspace carries, in assignment order. + * + * Resolved against the workspace's own host and nothing else. The cross-host projection is for + * surfaces that span hosts — a filter page, a manager — and it would answer here too, but two + * hosts are free to give the same name different colors, and a merged catalog settles that by + * precedence and then alphabetical serverId. A row that drew its label in a color its host never + * assigned would be lying about the one thing the color is for. + * + * A workspace stores names; the color lives in that host's catalog, so a name the catalog does + * not know is dropped rather than drawn in an invented color. The catalog survives the host going + * offline, so this only drops names while a host's first list is still in flight. + */ +export function useWorkspaceLabelDefinitions( + serverId: string, + names: readonly string[] | undefined, +): readonly WorkspaceLabelDefinition[] { + const hostsById = useWorkspaceLabels((state) => state.hosts); + return useMemo( + () => selectWorkspaceLabelDefinitions({ hostsById, serverId, names }), + [hostsById, names, serverId], + ); +} + +export function selectWorkspaceLabelDefinitions(input: { + hostsById: Readonly>; + serverId: string; + names: readonly string[] | undefined; +}): readonly WorkspaceLabelDefinition[] { + const { hostsById, serverId, names } = input; + if (!names || names.length === 0) return EMPTY_LABEL_DEFINITIONS; + const catalog = hostsById[serverId]?.labels ?? []; + const byKey = new Map(catalog.map((label) => [workspaceLabelKey(label.name), label])); + return names.flatMap((name) => { + const definition = byKey.get(workspaceLabelKey(name)); + return definition ? [definition] : []; + }); +} + +const EMPTY_LABEL_DEFINITIONS: readonly WorkspaceLabelDefinition[] = []; + +interface HostConnection { + client: DaemonClient; + replica: HostWorkspaceLabelReplica; + unsubscribe: () => void; +} + +class WorkspaceLabelsController { + private readonly replicas = new Map(); + private readonly connections = new Map(); + + async connect(input: { + serverId: string; + client: DaemonClient; + supportsWorkspaceLabels: boolean; + }): Promise { + this.disconnect(input.serverId); + const replica = this.replicas.get(input.serverId) ?? new HostWorkspaceLabelReplica(); + this.replicas.set(input.serverId, replica); + if (!input.supportsWorkspaceLabels) { + this.publish(input.serverId, replica, "unsupported", null); + return; + } + + let connection: HostConnection | undefined; + const unsubscribe = input.client.on("workspace.label.update", (message) => { + if (this.connections.get(input.serverId) !== connection) return; + if (!replica.applyUpdate(message)) { + void this.refresh(input.serverId).catch(() => undefined); + return; + } + this.publish(input.serverId, replica, "online", null); + }); + connection = { client: input.client, replica, unsubscribe }; + this.connections.set(input.serverId, connection); + await this.refresh(input.serverId); + } + + disconnect(serverId: string): void { + this.connections.get(serverId)?.unsubscribe(); + this.connections.delete(serverId); + const replica = this.replicas.get(serverId); + if (replica) this.publish(serverId, replica, "offline", null); + } + + async setAssignment(input: { + serverId: string; + workspaceId: string; + label: WorkspaceLabelDefinition; + assigned: boolean; + }) { + return this.mutate(input.serverId, (client) => client.setWorkspaceLabel(input)); + } + + async update(input: { + serverId: string; + name: string; + newName?: string; + color?: WorkspaceLabelDefinition["color"]; + }) { + return this.mutate(input.serverId, (client) => client.updateWorkspaceLabel(input)); + } + + async delete(input: { serverId: string; name: string }) { + return this.mutate(input.serverId, (client) => client.deleteWorkspaceLabel(input)); + } + + async inspectDelete(input: { serverId: string; name: string }) { + return this.mutate(input.serverId, (client) => client.inspectWorkspaceLabelDelete(input)); + } + + private async refresh(serverId: string): Promise { + const connection = this.connections.get(serverId); + if (!connection) return; + try { + const payload = await connection.client.listWorkspaceLabels({ + subscriptionId: `workspace-labels:${serverId}`, + sync: connection.replica.snapshot().cursor, + }); + if (this.connections.get(serverId) !== connection) return; + connection.replica.applyList(payload); + this.publish(serverId, connection.replica, "online", null); + } catch (error) { + if (this.connections.get(serverId) !== connection) throw error; + this.publish( + serverId, + connection.replica, + "online", + error instanceof Error ? error.message : i18n.t("workspaceLabels.errors.load"), + ); + throw error; + } + } + + private async mutate(serverId: string, operation: (client: DaemonClient) => Promise) { + const connection = this.connections.get(serverId); + if (!connection) throw new Error(i18n.t("workspaceLabels.manage.offline")); + try { + return await operation(connection.client); + } catch (error) { + await this.refresh(serverId).catch(() => undefined); + throw error; + } + } + + private publish( + serverId: string, + replica: HostWorkspaceLabelReplica, + status: WorkspaceLabelHostSnapshot["status"], + error: string | null, + ): void { + useWorkspaceLabels.getState().setHost({ + serverId, + labels: replica.snapshot().labels, + status, + error, + }); + } +} + +export const workspaceLabels = new WorkspaceLabelsController(); diff --git a/packages/app/src/workspace-labels/internal/host-replica.ts b/packages/app/src/workspace-labels/internal/host-replica.ts new file mode 100644 index 000000000..c5e176c7e --- /dev/null +++ b/packages/app/src/workspace-labels/internal/host-replica.ts @@ -0,0 +1,48 @@ +import type { WorkspaceLabelDefinition } from "@getpaseo/protocol/workspace-labels"; +import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels"; +import type { WorkspaceLabelListPayload } from "@getpaseo/client/internal/daemon-client"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; + +type WorkspaceLabelUpdate = Extract; + +export class HostWorkspaceLabelReplica { + private labels = new Map(); + private cursor: { generation: string; afterSeq: number } | undefined; + + snapshot(): { + labels: WorkspaceLabelDefinition[]; + cursor?: { generation: string; afterSeq: number }; + } { + return { + labels: [...this.labels.values()], + ...(this.cursor ? { cursor: this.cursor } : {}), + }; + } + + applyList(payload: WorkspaceLabelListPayload): void { + if (payload.sync.mode === "snapshot") this.labels.clear(); + for (const removal of payload.sync.removals) + this.labels.delete(workspaceLabelKey(removal.name)); + for (const label of payload.labels) this.labels.set(workspaceLabelKey(label.name), label); + this.cursor = { generation: payload.sync.generation, afterSeq: payload.sync.headSeq }; + } + + applyUpdate(message: WorkspaceLabelUpdate): boolean { + const update = message.payload; + if ( + !this.cursor || + update.generation !== this.cursor.generation || + update.seq !== this.cursor.afterSeq + 1 + ) { + return false; + } + if (update.kind === "remove") { + this.labels.delete(workspaceLabelKey(update.name)); + } else { + if (update.previousName) this.labels.delete(workspaceLabelKey(update.previousName)); + this.labels.set(workspaceLabelKey(update.label.name), update.label); + } + this.cursor = { generation: update.generation, afterSeq: update.seq }; + return true; + } +} diff --git a/packages/app/src/workspace-labels/internal/merge.ts b/packages/app/src/workspace-labels/internal/merge.ts new file mode 100644 index 000000000..fd25bd6e5 --- /dev/null +++ b/packages/app/src/workspace-labels/internal/merge.ts @@ -0,0 +1,28 @@ +import type { WorkspaceLabelDefinition } from "@getpaseo/protocol/workspace-labels"; +import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels"; + +export interface HostLabelCatalog { + serverId: string; + labels: readonly WorkspaceLabelDefinition[]; +} + +export function mergeWorkspaceLabelCatalogs(input: { + catalogs: readonly HostLabelCatalog[]; + targetServerId?: string; +}): WorkspaceLabelDefinition[] { + const ordered = [...input.catalogs].sort((left, right) => { + if (left.serverId === input.targetServerId) return -1; + if (right.serverId === input.targetServerId) return 1; + return left.serverId.localeCompare(right.serverId); + }); + const merged = new Map(); + for (const catalog of ordered) { + for (const label of catalog.labels) { + const key = workspaceLabelKey(label.name); + if (!merged.has(key)) merged.set(key, label); + } + } + return [...merged.values()].sort((left, right) => + left.name.localeCompare(right.name, undefined, { sensitivity: "base" }), + ); +} diff --git a/packages/app/src/workspace-labels/internal/picker-model.ts b/packages/app/src/workspace-labels/internal/picker-model.ts new file mode 100644 index 000000000..b40fd4d21 --- /dev/null +++ b/packages/app/src/workspace-labels/internal/picker-model.ts @@ -0,0 +1,25 @@ +import type { WorkspaceLabelDefinition } from "@getpaseo/protocol/workspace-labels"; +import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels"; + +export interface WorkspaceLabelPickerRow extends WorkspaceLabelDefinition { + assigned: boolean; +} + +/** + * The host's catalog, each entry told whether this workspace carries it. + * + * A workspace stores names and the catalog stores definitions, and the two are only ever compared + * through `workspaceLabelKey` — a host that accepted "Blocked" answers an assignment recorded as + * "blocked". + */ +export function buildWorkspaceLabelPickerRows(input: { + labels: readonly WorkspaceLabelDefinition[]; + assigned: readonly string[]; +}): WorkspaceLabelPickerRow[] { + const assigned = new Set(input.assigned.map(workspaceLabelKey)); + return input.labels.map((label) => ({ + name: label.name, + color: label.color, + assigned: assigned.has(workspaceLabelKey(label.name)), + })); +} diff --git a/packages/app/src/workspace-labels/internal/workflow-model.ts b/packages/app/src/workspace-labels/internal/workflow-model.ts new file mode 100644 index 000000000..171b7e673 --- /dev/null +++ b/packages/app/src/workspace-labels/internal/workflow-model.ts @@ -0,0 +1,328 @@ +import { + normalizeWorkspaceLabelName, + workspaceLabelKey, + type WorkspaceLabelDefinition, +} from "@getpaseo/protocol/workspace-labels"; +import { buildWorkspaceLabelPickerRows, type WorkspaceLabelPickerRow } from "./picker-model"; +import { i18n } from "@/i18n/i18next"; + +type Listener = () => void; + +class ObservableModel { + private readonly listeners = new Set(); + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + protected publish(): void { + for (const listener of this.listeners) listener(); + } +} + +export interface WorkspaceLabelPickerModelSnapshot { + rows: readonly WorkspaceLabelPickerRow[]; + error: string | null; + online: boolean; + pendingNames: string[]; +} + +/** + * Assigning labels to one workspace: a row per catalog entry, and a toggle per row. + * + * Every press toggles and the menu stays open, so the model has no say in the surface's + * lifetime — it holds no `close`, and picking a label is not an answer to a question. What it + * does own is the one-in-flight-per-label rule, which is what keeps a double tap from firing two + * opposite mutations at the same label. + */ +export class WorkspaceLabelPickerModel extends ObservableModel { + private error: string | null = null; + private online = false; + private labels: readonly WorkspaceLabelDefinition[] = []; + private assigned: readonly string[] = []; + private readonly pending = new Map>(); + private currentSnapshot: WorkspaceLabelPickerModelSnapshot; + + constructor( + private readonly dependencies: { + mutate: (input: { label: WorkspaceLabelDefinition; assigned: boolean }) => Promise; + }, + ) { + super(); + this.currentSnapshot = this.buildSnapshot(); + } + + snapshot = (): WorkspaceLabelPickerModelSnapshot => this.currentSnapshot; + + sync(input: { + labels: readonly WorkspaceLabelDefinition[]; + assigned: readonly string[]; + online: boolean; + }): void { + this.labels = input.labels; + this.assigned = input.assigned; + this.online = input.online; + this.commit(); + } + + toggle(label: WorkspaceLabelDefinition, assigned: boolean): Promise { + const key = workspaceLabelKey(label.name); + const inFlight = this.pending.get(key); + if (inFlight) return inFlight; + this.error = null; + const operation = this.dependencies + .mutate({ label, assigned }) + .then(() => true) + .catch((cause: unknown) => { + this.error = workspaceLabelErrorMessage(cause); + return false; + }) + .finally(() => { + this.pending.delete(key); + this.commit(); + }); + this.pending.set(key, operation); + this.commit(); + return operation; + } + + private commit(): void { + this.currentSnapshot = this.buildSnapshot(); + this.publish(); + } + + private buildSnapshot(): WorkspaceLabelPickerModelSnapshot { + return { + rows: buildWorkspaceLabelPickerRows({ labels: this.labels, assigned: this.assigned }), + error: this.error, + online: this.online, + pendingNames: [...this.pending.keys()], + }; + } +} + +export interface WorkspaceLabelManagerHost { + serverId: string; + label: string; + status: "offline" | "online" | "unsupported"; + labels: readonly WorkspaceLabelDefinition[]; + error?: string | null; +} + +/** The label being edited, as typed so far. Absent means the list is what's on screen. */ +export interface WorkspaceLabelManagerDraft { + /** The catalog name the edit targets. Fixed for the life of the draft. */ + name: string; + draftName: string; + color: WorkspaceLabelDefinition["color"]; +} + +export interface WorkspaceLabelManagerSnapshot { + hosts: readonly WorkspaceLabelManagerHost[]; + serverId: string; + draft: WorkspaceLabelManagerDraft | null; + query: string; + error: string | null; + pending: boolean; + host: WorkspaceLabelManagerHost | null; + labels: WorkspaceLabelDefinition[]; + /** The catalog entry behind the open draft, or null when nothing is being edited. */ + editing: WorkspaceLabelDefinition | null; + /** Whether the draft differs from the label it targets — what Save is worth pressing for. */ + dirty: boolean; +} + +export class WorkspaceLabelManagerModel extends ObservableModel { + private hosts: readonly WorkspaceLabelManagerHost[] = []; + private serverId = ""; + private draft: WorkspaceLabelManagerDraft | null = null; + private query = ""; + private error: string | null = null; + private operation: Promise | null = null; + private currentSnapshot: WorkspaceLabelManagerSnapshot; + + constructor( + private readonly dependencies: { + update: (input: { + serverId: string; + name: string; + newName?: string; + color?: WorkspaceLabelDefinition["color"]; + }) => Promise<{ label?: WorkspaceLabelDefinition }>; + inspectDelete: (input: { serverId: string; name: string }) => Promise<{ + affectedWorkspaceCount: number; + }>; + delete: (input: { serverId: string; name: string }) => Promise; + }, + ) { + super(); + this.currentSnapshot = this.buildSnapshot(); + } + + snapshot = (): WorkspaceLabelManagerSnapshot => this.currentSnapshot; + + syncHosts(hosts: readonly WorkspaceLabelManagerHost[]): void { + this.hosts = hosts; + if (!this.operation && !hosts.some((host) => host.serverId === this.serverId)) { + this.serverId = hosts[0]?.serverId ?? ""; + this.draft = null; + } + this.commit(); + } + + selectHost(serverId: string): void { + if (this.operation || serverId === this.serverId) return; + this.serverId = serverId; + this.draft = null; + this.error = null; + this.commit(); + } + + /** Open the edit view on a label, seeded with what that label currently is. */ + edit(label: WorkspaceLabelDefinition): void { + if (this.operation) return; + this.draft = { name: label.name, draftName: label.name, color: label.color }; + this.error = null; + this.commit(); + } + + /** Leave the edit view without applying anything. */ + cancelEdit(): void { + if (this.operation) return; + this.draft = null; + this.error = null; + this.commit(); + } + + /** Back to a freshly opened manager: no draft, no search, no stale error. */ + reset(): void { + if (this.operation) return; + this.draft = null; + this.query = ""; + this.error = null; + this.commit(); + } + + setDraftName(name: string): void { + if (!this.draft) return; + this.draft = { ...this.draft, draftName: name }; + this.commit(); + } + + setDraftColor(color: WorkspaceLabelDefinition["color"]): void { + if (!this.draft) return; + this.draft = { ...this.draft, color }; + this.commit(); + } + + setQuery(query: string): void { + this.query = query; + this.commit(); + } + + /** + * Apply the draft as one update, and close the edit view only once it lands. + * + * A failure keeps the draft exactly as typed: the host either took both fields or neither, so + * there is nothing to reconcile and nothing to explain beyond the error. + */ + save(): Promise { + const draft = this.draft; + const editing = this.currentSnapshot.editing; + // Nothing to apply is nothing to send: `dirty` already accounts for an empty or + // whitespace-only name, which is not an edit the host could carry out. + if (!draft || !editing || !this.currentSnapshot.dirty) return Promise.resolve(); + const newName = normalizeWorkspaceLabelName(draft.draftName); + return this.run(async () => { + await this.dependencies.update({ + serverId: this.serverId, + name: editing.name, + ...(newName === editing.name ? {} : { newName }), + ...(draft.color === editing.color ? {} : { color: draft.color }), + }); + this.draft = null; + }); + } + + delete(confirm: (affectedWorkspaceCount: number) => Promise): Promise { + const editing = this.currentSnapshot.editing; + if (!editing) return Promise.resolve(); + const target = { serverId: this.serverId, name: editing.name }; + return this.run(async () => { + const inspected = await this.dependencies.inspectDelete(target); + if (!(await confirm(inspected.affectedWorkspaceCount))) return; + await this.dependencies.delete(target); + this.draft = null; + }); + } + + private run(action: () => Promise): Promise { + if (this.operation) return this.operation; + this.error = null; + this.operation = action() + .catch((cause: unknown) => { + this.error = workspaceLabelErrorMessage(cause); + }) + .finally(() => { + this.operation = null; + this.commit(); + }); + this.commit(); + return this.operation; + } + + private commit(): void { + this.currentSnapshot = this.buildSnapshot(); + this.publish(); + } + + private buildSnapshot(): WorkspaceLabelManagerSnapshot { + const host = this.hosts.find((candidate) => candidate.serverId === this.serverId) ?? null; + const labels = (host?.labels ?? []).filter((label) => + label.name.toLocaleLowerCase().includes(this.query.trim().toLocaleLowerCase()), + ); + const draft = this.draft; + const editing = + host?.labels.find( + (label) => + draft !== null && workspaceLabelKey(label.name) === workspaceLabelKey(draft.name), + ) ?? null; + // An empty name is not an edit the host could carry out, so it is not dirty either — which + // is the one place Save's enablement is decided. + const draftName = draft ? normalizeWorkspaceLabelName(draft.draftName) : ""; + return { + hosts: this.hosts, + serverId: this.serverId, + draft, + query: this.query, + error: this.error, + pending: this.operation !== null, + host, + labels: [...labels], + editing, + dirty: + draft !== null && + editing !== null && + draftName.length > 0 && + (draftName !== editing.name || draft.color !== editing.color), + }; + } +} + +/** + * One phrasing for a failed label mutation, wherever the failure surfaces. + * + * The client appends the correlation it failed on — `requestType=… code=…` — to every RPC error, + * which is what a log wants and not what a user reads under a name field. The fields are on the + * error too, so the suffix is removed by identity rather than by guessing at the format. + */ +export function workspaceLabelErrorMessage(cause: unknown): string { + if (!(cause instanceof Error)) return i18n.t("workspaceLabels.errors.update"); + const { requestType, code } = cause as { requestType?: string; code?: string }; + const message = cause.message + .replace(` requestType=${requestType}`, "") + .replace(` code=${code}`, "") + .trim(); + return message || i18n.t("workspaceLabels.errors.update"); +} diff --git a/packages/app/src/workspace-labels/manager-modal.tsx b/packages/app/src/workspace-labels/manager-modal.tsx new file mode 100644 index 000000000..0dc8f1c53 --- /dev/null +++ b/packages/app/src/workspace-labels/manager-modal.tsx @@ -0,0 +1,399 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactElement, +} from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { Pencil } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + workspaceLabelKey, + type WorkspaceLabelColor, + type WorkspaceLabelDefinition, +} from "@getpaseo/protocol/workspace-labels"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { HostFilter } from "@/components/hosts/host-filter"; +import { Button } from "@/components/ui/button"; +import type { FieldControlSize } from "@/components/ui/control-geometry"; +import { Field, FormTextInput } from "@/components/ui/form-field"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import { useHosts } from "@/runtime/host-runtime"; +import type { Theme } from "@/styles/theme"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { + createWorkspaceLabelManagerModel, + useWorkspaceLabelProjection, + workspaceLabels, + type WorkspaceLabelManagerDraft, +} from "@/workspace-labels"; +import { WorkspaceLabelDot, WorkspaceLabelSwatchRow } from "./swatch"; +import { useTranslation } from "react-i18next"; + +/** The size the host pill's own glyphs use, so the row's trailing mark matches the controls above it. */ +const EDIT_ICON_SIZE = 14; + +/** Grows the pencil to a 44pt target without moving it off the trailing rail. */ +const EDIT_HIT_SLOP = (44 - EDIT_ICON_SIZE) / 2; + +const ThemedPencil = withUnistyles(Pencil); +const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +/** + * The whole label catalog of one host, and editing any one of them. + * + * Two views, one sheet: the list, and the edit view the pencil opens. The edit view is reached + * the way every other sheet reaches a second page — the header's back arrow returns from it — + * because a label row is a label, not a button that swaps the surface under you. + */ +export function WorkspaceLabelManagerModal({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}): ReactElement { + const { t } = useTranslation(); + const hosts = useHosts(); + const { hosts: labelHosts } = useWorkspaceLabelProjection(); + const snapshots = useMemo( + () => Object.fromEntries(labelHosts.map((host) => [host.serverId, host])), + [labelHosts], + ); + const [model] = useState(() => + createWorkspaceLabelManagerModel({ + update: (input) => workspaceLabels.update(input), + inspectDelete: (input) => workspaceLabels.inspectDelete(input), + delete: (input) => workspaceLabels.delete(input), + }), + ); + + useEffect(() => { + // Closing ends the edit, so reopening lands on the list rather than on whatever was half + // typed a session ago. + if (!visible) { + model.reset(); + return; + } + model.syncHosts( + hosts.map((host) => ({ + serverId: host.serverId, + label: host.label || host.serverId, + status: snapshots[host.serverId]?.status ?? "offline", + labels: snapshots[host.serverId]?.labels ?? [], + error: snapshots[host.serverId]?.error, + })), + ); + }, [hosts, model, snapshots, visible]); + const state = useSyncExternalStore(model.subscribe, model.snapshot, model.snapshot); + const draft = state.draft; + const editing = state.editing; + const disabled = state.host?.status !== "online" || state.pending; + + const selectHost = useCallback((serverId: string) => model.selectHost(serverId), [model]); + const setQuery = useCallback((value: string) => model.setQuery(value), [model]); + const edit = useCallback((label: WorkspaceLabelDefinition) => model.edit(label), [model]); + const cancelEdit = useCallback(() => model.cancelEdit(), [model]); + const setDraftName = useCallback((value: string) => model.setDraftName(value), [model]); + const setDraftColor = useCallback( + (color: WorkspaceLabelColor) => model.setDraftColor(color), + [model], + ); + const save = useCallback(() => { + void model.save(); + }, [model]); + const remove = useCallback((): void => { + if (!editing) return; + void model.delete((affected) => + confirmDialog({ + title: t("workspaceLabels.manage.deleteTitle", { name: editing.name }), + message: t("workspaceLabels.manage.deleteMessage", { count: affected }), + confirmLabel: t("workspaceLabels.manage.delete"), + destructive: true, + }), + ); + }, [editing, model, t]); + + const isEditing = draft !== null; + // Search belongs to the sheet's header, where it spans the header's own rails and needs no + // layout from this caller. `resetKey` empties the uncontrolled field on the same event that + // clears the model's query, so the box and the list never disagree about what is filtered. + const header = useMemo( + () => + isEditing + ? { + title: t("workspaceLabels.manage.edit"), + back: { onPress: cancelEdit }, + } + : { + title: t("workspaceLabels.manage.title"), + search: { + onChange: setQuery, + resetKey: Number(visible), + placeholder: t("workspaceLabels.manage.search"), + testID: "workspace-label-manager-search", + }, + }, + [cancelEdit, isEditing, setQuery, t, visible], + ); + const footer = useMemo( + () => + isEditing ? ( + + + + + ) : undefined, + [disabled, isEditing, remove, save, state.dirty, state.pending, t], + ); + + return ( + + {draft && editing ? ( + + ) : ( + <> + {hosts.length > 1 ? ( + + ) : null} + + {state.labels.map((label) => ( + + ))} + {state.labels.length === 0 ? ( + {t("workspaceLabels.manage.empty")} + ) : null} + + {state.host?.status === "offline" ? ( + {t("workspaceLabels.manage.offline")} + ) : null} + {state.host?.status === "unsupported" ? ( + {t("workspaceLabels.manage.updateHost")} + ) : null} + {(state.error ?? state.host?.error) ? ( + + {state.error ?? state.host?.error} + + ) : null} + + )} + + ); +} + +const managerHostOptionTestID = (serverId: string) => `workspace-label-manager-host-${serverId}`; + +/** + * One label: its colour, its name, and the pencil that opens it. + * + * Hover lives on the plain outer `View` and press on the inner `Pressable`, which is the one + * shape that survives a pressable inside a hover target (docs/hover.md). The pencil fades rather + * than mounts, so revealing it cannot move the row out from under the pointer. + */ +function WorkspaceLabelManagerRow({ + label, + disabled, + onEdit, +}: { + label: WorkspaceLabelDefinition; + disabled: boolean; + onEdit: (label: WorkspaceLabelDefinition) => void; +}): ReactElement { + const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); + const [isHovered, setIsHovered] = useState(false); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + const edit = useCallback(() => onEdit(label), [label, onEdit]); + const revealed = isHovered || isNative || isCompact; + const editStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + styles.rowEdit, + !revealed && styles.rowEditHidden, + pressed && styles.rowEditPressed, + ], + [revealed], + ); + + return ( + + + + {label.name} + + + + + + ); +} + +/** + * A label's name and colour, changed together and committed once by the footer's Save. + * + * Picking a colour picks a colour — nothing leaves for the host until Save, so a failure has one + * thing to report and the draft is still exactly what was typed. + */ +function WorkspaceLabelEditView({ + draft, + disabled, + error, + onChangeName, + onChangeColor, + onSubmit, +}: { + draft: WorkspaceLabelManagerDraft; + disabled: boolean; + error: string | null; + onChangeName: (value: string) => void; + onChangeColor: (color: WorkspaceLabelColor) => void; + onSubmit: () => void; +}): ReactElement { + const { t } = useTranslation(); + const size: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm"; + return ( + <> + + + + + + + {error ? ( + + {error} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + list: { + gap: theme.spacing[1], + }, + // Fixed height so the pencil appearing cannot resize the row under the pointer. The dot leads + // at the content inset and the pencil ends on it; nothing here indents, so the list holds one + // rail down its whole length. + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 32, + }, + rowName: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + rowEdit: { + // The glyph is the whole control — its 44pt target comes from hitSlop, which grows outward + // and leaves the trailing rail where it is. + alignItems: "center", + justifyContent: "center", + }, + rowEditHidden: { + opacity: 0, + }, + rowEditPressed: { + opacity: 0.6, + }, + muted: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + error: { + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + }, + footer: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + }, + saveButton: { + minWidth: 112, + }, +})); diff --git a/packages/app/src/workspace-labels/picker.tsx b/packages/app/src/workspace-labels/picker.tsx new file mode 100644 index 000000000..7afdaeabe --- /dev/null +++ b/packages/app/src/workspace-labels/picker.tsx @@ -0,0 +1,287 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactElement, +} from "react"; +import { View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { Plus } from "lucide-react-native"; +import { + normalizeWorkspaceLabelName, + WORKSPACE_LABEL_COLORS, + type WorkspaceLabelColor, + type WorkspaceLabelDefinition, +} from "@getpaseo/protocol/workspace-labels"; +import { + MenuHint, + MenuItem, + MenuSeparator, + MenuSubTrigger, + MenuTextField, + menuRowContentInset, + useMenuContext, + type MenuPageDefinition, +} from "@/components/ui/menu"; +import { + createWorkspaceLabelPickerModel, + useWorkspaceLabelProjection, + workspaceLabelErrorMessage, + workspaceLabels, + type WorkspaceLabelPickerRow, +} from "@/workspace-labels"; +import type { Theme } from "@/styles/theme"; +import { WorkspaceLabelDot, WorkspaceLabelSwatchRow } from "./swatch"; + +/** The `MenuSubTrigger` on a workspace's menu that opens the assign page. */ +export const WORKSPACE_LABEL_PAGE_ID = "workspaceLabels"; +const WORKSPACE_LABEL_CREATE_PAGE_ID = "workspaceLabelsCreate"; + +/** The menu's own icon size, matching the trailing check on every other row. */ +const MENU_ICON_SIZE = 14; + +const ThemedPlus = withUnistyles(Plus); +const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +// The create row keeps the leading column the label rows above it sit in. Without a mark there +// its label would start on the text rail while every label starts 24pt further in, and the last +// row of the page would read as one that slipped out of the list. +const CREATE_LEADING = ; + +/** The colour a new label takes until you say otherwise. */ +const DEFAULT_CREATE_COLOR: WorkspaceLabelColor = WORKSPACE_LABEL_COLORS[0]; + +export interface WorkspaceLabelTarget { + serverId: string; + workspaceId: string; + /** Label names the workspace already carries, from the sidebar entry. */ + labels: readonly string[]; +} + +const NO_PAGES: readonly MenuPageDefinition[] = []; + +/** + * The two pages behind a workspace's `Labels` row, for whichever menu is asking. + * + * Both the kebab dropdown and the row's context menu open the same thing, and a page defined + * twice is a page that will be improved once. + */ +export function useWorkspaceLabelMenuPages( + target: WorkspaceLabelTarget | null, +): readonly MenuPageDefinition[] { + const { t } = useTranslation(); + return useMemo(() => { + if (!target) return NO_PAGES; + return [ + { + id: WORKSPACE_LABEL_PAGE_ID, + title: t("workspaceLabels.title"), + content: ( + + ), + }, + { + id: WORKSPACE_LABEL_CREATE_PAGE_ID, + title: t("workspaceLabels.create"), + // A page you type into is not one the pointer opens or dismisses on its own. + hoverIntent: false, + content: ( + + ), + }, + ]; + }, [t, target]); +} + +/** + * Every label on the host, with the ones this workspace carries ticked. + * + * Assigning is multi-select, so a press toggles and nothing closes; the menu ends when it is + * dismissed. Assignment is the trailing check `MenuItem` already draws for a chosen row, and the + * colour goes in the leading slot, which puts both on the rails every other row in the menu uses. + */ +function WorkspaceLabelPickerPage({ + serverId, + workspaceId, + assignedLabels, +}: { + serverId: string; + workspaceId: string; + assignedLabels: readonly string[]; +}): ReactElement { + const { t } = useTranslation(); + const { labels, targetHost: host } = useWorkspaceLabelProjection(serverId); + const model = useMemo( + () => + createWorkspaceLabelPickerModel({ + mutate: ({ label, assigned }) => + workspaceLabels.setAssignment({ serverId, workspaceId, label, assigned }), + }), + [serverId, workspaceId], + ); + useEffect(() => { + model.sync({ labels, assigned: assignedLabels, online: host?.status === "online" }); + }, [assignedLabels, host?.status, labels, model]); + const snapshot = useSyncExternalStore(model.subscribe, model.snapshot, model.snapshot); + const offline = !snapshot.online; + const pending = useMemo(() => new Set(snapshot.pendingNames), [snapshot.pendingNames]); + const toggle = useCallback( + (label: WorkspaceLabelDefinition, assigned: boolean) => { + void model.toggle(label, assigned); + }, + [model], + ); + + return ( + <> + {snapshot.rows.map((row) => ( + + ))} + {snapshot.rows.length > 0 ? : null} + + {t("workspaceLabels.create")} + + {snapshot.error ? ( + {snapshot.error} + ) : null} + {host?.status === "unsupported" ? ( + {t("workspaceLabels.updateHostUse")} + ) : null} + + ); +} + +function WorkspaceLabelAssignRow({ + row, + offline, + pending, + onToggle, +}: { + row: WorkspaceLabelPickerRow; + offline: boolean; + pending: boolean; + onToggle: (label: WorkspaceLabelDefinition, assigned: boolean) => void; +}): ReactElement { + const leading = useMemo(() => , [row.color]); + const select = useCallback( + () => onToggle({ name: row.name, color: row.color }, !row.assigned), + [onToggle, row.assigned, row.color, row.name], + ); + return ( + + {row.name} + + ); +} + +/** + * Naming a new label and giving it a colour, in place of the list rather than under it. + * + * Picking a colour picks a colour: the label is created by the one row that says so, and only + * once the name is not empty. A failure keeps the draft and says what happened, because the + * alternative is retyping the name to find out whether the host is still there. + */ +function WorkspaceLabelCreatePage({ + serverId, + workspaceId, +}: { + serverId: string; + workspaceId: string; +}): ReactElement { + const { t } = useTranslation(); + const menu = useMenuContext("WorkspaceLabelCreatePage"); + const { targetHost: host } = useWorkspaceLabelProjection(serverId); + const [name, setName] = useState(""); + const [color, setColor] = useState(DEFAULT_CREATE_COLOR); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const offline = host?.status !== "online"; + + const submit = useCallback(() => { + const trimmed = normalizeWorkspaceLabelName(name); + if (!trimmed || pending) return; + setPending(true); + setError(null); + workspaceLabels + .setAssignment({ + serverId, + workspaceId, + label: { name: trimmed, color }, + assigned: true, + }) + .then(() => menu.goBack()) + .catch((cause: unknown) => setError(workspaceLabelErrorMessage(cause))) + .finally(() => setPending(false)); + }, [color, menu, name, pending, serverId, workspaceId]); + + return ( + <> + + + + + + + {t("workspaceLabels.createConfirm")} + + {error ? {error} : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // The swatches start where a row's label starts, so the page reads down one rail: field text, + // first swatch, commit row. The inset is the text's box, which leaves the round swatch hanging + // a hair left of the letters beside it — that overshoot is what makes a circle look aligned + // with a flat stem, so it is the rail rather than a miss. Vertical room of its own because the + // page's row gap is zero. + swatches: { + paddingHorizontal: menuRowContentInset(theme), + paddingVertical: theme.spacing[2], + }, +})); diff --git a/packages/app/src/workspace-labels/swatch.tsx b/packages/app/src/workspace-labels/swatch.tsx new file mode 100644 index 000000000..f57e67e5b --- /dev/null +++ b/packages/app/src/workspace-labels/swatch.tsx @@ -0,0 +1,180 @@ +import { useCallback, useMemo, type ReactElement } from "react"; +import { Pressable, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { + WORKSPACE_LABEL_COLORS, + type WorkspaceLabelColor, +} from "@getpaseo/protocol/workspace-labels"; +import { identityForeground } from "@/styles/identity-colors"; +import { i18n } from "@/i18n/i18next"; +import type { Theme } from "@/styles/theme"; + +/** + * A label's color as ink: the dot in front of its name, the swatches you pick it from, and the + * tint mapping the sidebar's label group headers hand to a glyph. + * + * The ten names are the identity table's ten (`@/styles/identity-colors`) — the protocol happens + * to spell them out again as `WorkspaceLabelColor`, and every `identityForeground` call below is + * the compile-time proof that the two lists still agree. Nothing here picks a hex: a label sits + * on the same sidebar row as a host badge and a CI check, and the identity table is the one place + * that holds all three to a single contrast band. + */ + +/** Matches the status dot next to it in a menu row: small enough to mark, not to rank. */ +const DOT_SIZE = 10; + +/** Big enough to judge a color by, and the swatch grid's only unit — see `hitSlop` below. */ +const SWATCH_SIZE = 20; + +export function workspaceLabelColorName(color: WorkspaceLabelColor): string { + return i18n.t(`workspaceLabels.colors.${color}`); +} + +/** + * One stable mapping per color for the life of the module, for lucide glyphs that carry a + * label's color (`withUnistyles` re-subscribes when `uniProps` changes identity). The scheme is + * read inside the mapping, so switching themes repaints the glyph without a React render. + */ +export const WORKSPACE_LABEL_ICON_MAPPINGS: Record< + WorkspaceLabelColor, + (theme: Theme) => { color: string } +> = Object.fromEntries( + WORKSPACE_LABEL_COLORS.map((color) => [ + color, + (theme: Theme) => ({ color: identityForeground(color, theme.colorScheme) }), + ]), +) as Record { color: string }>; + +/** The mark that stands in for a label wherever its name is already spelled out beside it. */ +export function WorkspaceLabelDot({ color }: { color: WorkspaceLabelColor }): ReactElement { + return ; +} + +/** + * The ten colors a label can take, as one row of swatches. + * + * The swatch is the whole visual — the 44pt target comes from `hitSlop` growing outward, so the + * row's rhythm is set by the swatches themselves rather than by ten touch targets with color + * somewhere inside them. Selection is an inset ring, which React Native draws inside the box, so + * picking a color cannot reflow the row. + */ +export function WorkspaceLabelSwatchRow({ + value, + onChange, + disabled = false, + testID, +}: { + value: WorkspaceLabelColor | null; + onChange: (color: WorkspaceLabelColor) => void; + disabled?: boolean; + testID?: string; +}): ReactElement { + return ( + + {WORKSPACE_LABEL_COLORS.map((color) => ( + + ))} + + ); +} + +function WorkspaceLabelSwatch({ + color, + selected, + disabled, + onChange, +}: { + color: WorkspaceLabelColor; + selected: boolean; + disabled: boolean; + onChange: (color: WorkspaceLabelColor) => void; +}): ReactElement { + const select = useCallback(() => onChange(color), [color, onChange]); + const accessibilityState = useMemo(() => ({ checked: selected, disabled }), [disabled, selected]); + return ( + + ); +} + +// Grows the 20pt swatch to a 44pt target without moving it. Padding here would push its +// neighbours apart and leave the row's spacing to the touch targets rather than the color. +const SWATCH_HIT_SLOP = (44 - SWATCH_SIZE) / 2; + +const styles = StyleSheet.create((theme) => ({ + dot: { + width: DOT_SIZE, + height: DOT_SIZE, + borderRadius: theme.borderRadius.full, + flexShrink: 0, + }, + swatchRow: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + gap: theme.spacing[3], + }, + swatch: { + width: SWATCH_SIZE, + height: SWATCH_SIZE, + borderRadius: theme.borderRadius.full, + }, + swatchSelected: { + borderWidth: theme.borderWidth[2], + borderColor: theme.colors.foreground, + }, + // The one place a label color becomes a fill. Scheme-dependent, so it has to be a registered + // style rather than a computed value: `identityForeground` needs the active scheme, and a + // module-scope read would freeze on whichever theme happened to be up first. + fillViolet: { backgroundColor: identityForeground("violet", theme.colorScheme) }, + fillSky: { backgroundColor: identityForeground("sky", theme.colorScheme) }, + fillEmerald: { backgroundColor: identityForeground("emerald", theme.colorScheme) }, + fillOrange: { backgroundColor: identityForeground("orange", theme.colorScheme) }, + fillPink: { backgroundColor: identityForeground("pink", theme.colorScheme) }, + fillIndigo: { backgroundColor: identityForeground("indigo", theme.colorScheme) }, + fillTeal: { backgroundColor: identityForeground("teal", theme.colorScheme) }, + fillRed: { backgroundColor: identityForeground("red", theme.colorScheme) }, + fillAmber: { backgroundColor: identityForeground("amber", theme.colorScheme) }, + fillBlue: { backgroundColor: identityForeground("blue", theme.colorScheme) }, +})); + +// Read inside render, never into a module-scope table — see docs/unistyles.md. +function fillStyle(color: WorkspaceLabelColor) { + switch (color) { + case "violet": + return styles.fillViolet; + case "sky": + return styles.fillSky; + case "emerald": + return styles.fillEmerald; + case "orange": + return styles.fillOrange; + case "pink": + return styles.fillPink; + case "indigo": + return styles.fillIndigo; + case "teal": + return styles.fillTeal; + case "red": + return styles.fillRed; + case "amber": + return styles.fillAmber; + case "blue": + return styles.fillBlue; + } +} diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 5be5f7b5f..0ab1cd721 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -11,3 +11,17 @@ export const BottomSheetBackdrop = Stub; export const BottomSheetScrollView = PassThrough; export const BottomSheetView = PassThrough; export const BottomSheetTextInput = Stub; + +// The app imports the internal layout values to compute the visible sheet body. Browser unit tests +// never render the native sheet, but Vite still validates this module's export surface. +const sharedValue = (value: unknown) => ({ get: () => value }); + +export const KEYBOARD_STATUS = { SHOWN: "SHOWN" }; +export function useBottomSheetInternal() { + return { + animatedDetentsState: sharedValue({ detents: [] as number[] }), + animatedKeyboardState: sharedValue({ heightWithinContainer: 0, status: "HIDDEN" }), + animatedLayoutState: sharedValue({ containerHeight: 0, handleHeight: 0 }), + animatedPosition: sharedValue(0), + }; +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index a76332821..5e07dc1f2 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -681,6 +681,26 @@ export type FetchWorkspacesOptions = Omit["payload"]; +export type WorkspaceLabelAssignmentPayload = Extract< + SessionOutboundMessage, + { type: "workspace.label.assignment.set.response" } +>["payload"]; +export type WorkspaceLabelUpdatePayload = Extract< + SessionOutboundMessage, + { type: "workspace.label.update.response" } +>["payload"]; +export type WorkspaceLabelDeletePayload = Extract< + SessionOutboundMessage, + { type: "workspace.label.delete.response" } +>["payload"]; +export type WorkspaceLabelDeleteInspectPayload = Extract< + SessionOutboundMessage, + { type: "workspace.label.delete.inspect.response" } +>["payload"]; export type ProjectListPayload = Extract< SessionOutboundMessage, { type: "project.list.response" } @@ -2116,6 +2136,81 @@ export class DaemonClient { }); } + listWorkspaceLabels(options: { + subscriptionId: string; + sync?: { generation: string; afterSeq: number }; + requestId?: string; + }): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "workspace.label.list.request", + subscribe: { subscriptionId: options.subscriptionId }, + ...(options.sync ? { sync: options.sync } : {}), + }, + }); + } + + setWorkspaceLabel(options: { + workspaceId: string; + label: Extract< + SessionInboundMessage, + { type: "workspace.label.assignment.set.request" } + >["label"]; + assigned: boolean; + requestId?: string; + }): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "workspace.label.assignment.set.request", + workspaceId: options.workspaceId, + label: options.label, + assigned: options.assigned, + }, + }); + } + + updateWorkspaceLabel(options: { + name: string; + newName?: string; + color?: Extract["color"]; + requestId?: string; + }): Promise { + return this.sendNamespacedCorrelatedSessionRequest<"workspace.label.update.response">({ + requestId: options.requestId, + message: { + type: "workspace.label.update.request", + name: options.name, + ...(options.newName === undefined ? {} : { newName: options.newName }), + ...(options.color === undefined ? {} : { color: options.color }), + }, + }); + } + + deleteWorkspaceLabel(options: { + name: string; + requestId?: string; + }): Promise { + return this.sendNamespacedCorrelatedSessionRequest<"workspace.label.delete.response">({ + requestId: options.requestId, + message: { type: "workspace.label.delete.request", name: options.name }, + }); + } + + inspectWorkspaceLabelDelete(options: { + name: string; + requestId?: string; + }): Promise { + return this.sendNamespacedCorrelatedSessionRequest<"workspace.label.delete.inspect.response">({ + requestId: options.requestId, + message: { + type: "workspace.label.delete.inspect.request", + name: options.name, + }, + }); + } + async listProjects(options?: string | ProjectListOptions): Promise { const requestId = typeof options === "string" ? options : options?.requestId; const resolvedRequestId = this.createRequestId(requestId); diff --git a/packages/desktop/e2e/project-picker.spec.ts b/packages/desktop/e2e/project-picker.spec.ts index fb11c24b6..d3475b1b3 100644 --- a/packages/desktop/e2e/project-picker.spec.ts +++ b/packages/desktop/e2e/project-picker.spec.ts @@ -30,8 +30,14 @@ test("Browse opens the folder selected by the desktop dialog", async ({ const browse = page.getByRole("button", { name: /^Browse/ }); await expect(browse).toBeVisible({ timeout: 30_000 }); await browse.click(); + const dialogOptions = await waitForDirectoryDialog(page); + expect(dialogOptions).toEqual({ + createDirectory: true, + directory: true, + multiple: false, + }); - const projectId = await expectOpenedProject(page, projectPickerFixture.projectName); + const projectId = await expectOpenedProject(page); projectPickerFixture.rememberProjectId(projectId); await expectNewWorkspaceForAddedProject(page, { serverId: getServerId(), diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 72e9b51a4..dbceccd0a 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -5,6 +5,7 @@ import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js"; import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; import { AgentProviderSchema } from "./provider-manifest.js"; import { TOOL_CALL_ICON_NAMES } from "./agent-types.js"; +import { WORKSPACE_LABEL_COLORS } from "./workspace-labels.js"; import { ChatCreateRequestSchema, ChatListRequestSchema, @@ -984,6 +985,52 @@ export const WorkspacePinSetRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceLabelColorSchema = z.enum(WORKSPACE_LABEL_COLORS); +export const WorkspaceLabelDefinitionSchema = z.object({ + name: z.string(), + color: WorkspaceLabelColorSchema, +}); +const WorkspaceLabelSyncCursorSchema = z.object({ + generation: z.string(), + afterSeq: z.number().int().nonnegative(), +}); + +export const WorkspaceLabelListRequestSchema = z.object({ + type: z.literal("workspace.label.list.request"), + requestId: z.string(), + subscribe: z.object({ subscriptionId: z.string() }), + sync: WorkspaceLabelSyncCursorSchema.optional(), +}); +export const WorkspaceLabelAssignmentSetRequestSchema = z.object({ + type: z.literal("workspace.label.assignment.set.request"), + requestId: z.string(), + workspaceId: z.string(), + label: WorkspaceLabelDefinitionSchema, + assigned: z.boolean(), +}); +/** + * Editing a label is one operation. A name and a colour are two fields of one thing, and two + * RPCs can land half-applied — leaving the catalog in a state the user never asked for and the + * UI with nothing true to say. Both fields are optional; omitting one leaves it alone. + */ +export const WorkspaceLabelUpdateRequestSchema = z.object({ + type: z.literal("workspace.label.update.request"), + requestId: z.string(), + name: z.string(), + newName: z.string().optional(), + color: WorkspaceLabelColorSchema.optional(), +}); +export const WorkspaceLabelDeleteRequestSchema = z.object({ + type: z.literal("workspace.label.delete.request"), + requestId: z.string(), + name: z.string(), +}); +export const WorkspaceLabelDeleteInspectRequestSchema = z.object({ + type: z.literal("workspace.label.delete.inspect.request"), + requestId: z.string(), + name: z.string(), +}); + export const WorkspaceRecoveryInspectRequestSchema = z.object({ type: z.literal("workspace.recovery.inspect.request"), workspaceId: z.string(), @@ -2913,6 +2960,11 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProjectRemoveRequestSchema, WorkspaceTitleSetRequestSchema, WorkspacePinSetRequestSchema, + WorkspaceLabelListRequestSchema, + WorkspaceLabelAssignmentSetRequestSchema, + WorkspaceLabelUpdateRequestSchema, + WorkspaceLabelDeleteRequestSchema, + WorkspaceLabelDeleteInspectRequestSchema, WorkspaceRecoveryInspectRequestSchema, WorkspaceRecoveryRestoreRequestSchema, SetVoiceModeMessageSchema, @@ -3246,6 +3298,8 @@ export const ServerInfoStatusPayloadSchema = z providersSnapshotCwd: z.boolean().optional(), // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: z.boolean().optional(), + // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. + workspaceLabels: z.boolean().optional(), // COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old // checkoutGithubSetAutoMerge fallback after 2026-12-28. checkoutForgeSetAutoMerge: z.boolean().optional(), @@ -3655,6 +3709,8 @@ export const WorkspaceDescriptorPayloadSchema = z title: z.string().nullable().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove optional after 2027-01-12. pinnedAt: z.string().nullable().optional(), + // COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14. + labels: z.array(z.string()).optional(), archivingAt: z.string().nullable().optional().default(null), status: WorkspaceStateBucketSchema, // Best-effort workspace status entry timestamp. Old daemons omit the @@ -3873,6 +3929,69 @@ export const WorkspaceUpdateMessageSchema = z.object({ ]), }); +const WorkspaceLabelSyncMetadataSchema = z.object({ + mode: z.enum(["snapshot", "changes"]), + generation: z.string(), + headSeq: z.number().int().nonnegative(), + removals: z.array(z.object({ name: z.string(), seq: z.number().int().positive() })), +}); +export const WorkspaceLabelListResponseSchema = z.object({ + type: z.literal("workspace.label.list.response"), + payload: z.object({ + requestId: z.string(), + labels: z.array(WorkspaceLabelDefinitionSchema), + sync: WorkspaceLabelSyncMetadataSchema, + }), +}); +export const WorkspaceLabelUpdateSchema = z.object({ + type: z.literal("workspace.label.update"), + payload: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("upsert"), + label: WorkspaceLabelDefinitionSchema, + previousName: z.string().optional(), + generation: z.string(), + seq: z.number().int().positive(), + }), + z.object({ + kind: z.literal("remove"), + name: z.string(), + generation: z.string(), + seq: z.number().int().positive(), + }), + ]), +}); +export const WorkspaceLabelAssignmentSetResponseSchema = z.object({ + type: z.literal("workspace.label.assignment.set.response"), + payload: z.object({ + requestId: z.string(), + label: WorkspaceLabelDefinitionSchema, + workspaceLabels: z.array(z.string()), + }), +}); +export const WorkspaceLabelUpdateResponseSchema = z.object({ + type: z.literal("workspace.label.update.response"), + payload: z.object({ + requestId: z.string(), + label: WorkspaceLabelDefinitionSchema, + affectedWorkspaceCount: z.number().int().nonnegative(), + }), +}); +export const WorkspaceLabelDeleteResponseSchema = z.object({ + type: z.literal("workspace.label.delete.response"), + payload: z.object({ + requestId: z.string(), + affectedWorkspaceCount: z.number().int().nonnegative(), + }), +}); +export const WorkspaceLabelDeleteInspectResponseSchema = z.object({ + type: z.literal("workspace.label.delete.inspect.response"), + payload: z.object({ + requestId: z.string(), + affectedWorkspaceCount: z.number().int().nonnegative(), + }), +}); + export const ProjectUpdateMessageSchema = z.object({ type: z.literal("project.update"), payload: z.discriminatedUnion("kind", [ @@ -6068,6 +6187,12 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArtifactMessageSchema, AgentUpdateMessageSchema, WorkspaceUpdateMessageSchema, + WorkspaceLabelListResponseSchema, + WorkspaceLabelUpdateSchema, + WorkspaceLabelAssignmentSetResponseSchema, + WorkspaceLabelUpdateResponseSchema, + WorkspaceLabelDeleteResponseSchema, + WorkspaceLabelDeleteInspectResponseSchema, ProjectUpdateMessageSchema, ProjectListResponseMessageSchema, ScriptStatusUpdateMessageSchema, diff --git a/packages/protocol/src/messages.workspace-labels.test.ts b/packages/protocol/src/messages.workspace-labels.test.ts new file mode 100644 index 000000000..a773ca1a2 --- /dev/null +++ b/packages/protocol/src/messages.workspace-labels.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "vitest"; +import { + ServerInfoStatusPayloadSchema, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, +} from "./messages.js"; + +describe("workspace label wire schemas", () => { + test("keeps the capability optional for old daemons", () => { + expect( + ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "old-host", + features: {}, + }).features.workspaceLabels, + ).toBeUndefined(); + }); + + test("parses the explicit list subscription and sequenced response", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "workspace.label.list.request", + requestId: "req-1", + subscribe: { subscriptionId: "labels-1" }, + sync: { generation: "generation-1", afterSeq: 4 }, + }), + ).toMatchObject({ type: "workspace.label.list.request" }); + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.label.list.response", + payload: { + requestId: "req-1", + labels: [], + sync: { + mode: "changes", + generation: "generation-1", + headSeq: 4, + removals: [], + }, + }, + }), + ).toMatchObject({ type: "workspace.label.list.response", payload: { labels: [] } }); + }); + + test("accepts optional label data without requiring it on legacy workspace messages", () => { + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.label.update", + payload: { + kind: "upsert", + label: { name: "Needs review", color: "sky" }, + generation: "generation-1", + seq: 5, + }, + }), + ).toMatchObject({ payload: { label: { name: "Needs review", color: "sky" } } }); + }); + + test("carries a name and a colour on one edit, and neither is required", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "workspace.label.update.request", + requestId: "req-edit", + name: "Urgent", + newName: "Priority", + color: "sky", + }), + ).toMatchObject({ newName: "Priority", color: "sky" }); + // A colour-only edit and a name-only edit are the same operation with a field left out. + expect( + SessionInboundMessageSchema.parse({ + type: "workspace.label.update.request", + requestId: "req-color", + name: "Urgent", + color: "amber", + }), + ).toEqual({ + type: "workspace.label.update.request", + requestId: "req-color", + name: "Urgent", + color: "amber", + }); + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.label.update.response", + payload: { + requestId: "req-edit", + label: { name: "Priority", color: "sky" }, + affectedWorkspaceCount: 2, + }, + }), + ).toMatchObject({ payload: { affectedWorkspaceCount: 2 } }); + }); + + test("uses a distinct operation for authoritative non-mutating delete inspection", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "workspace.label.delete.inspect.request", + requestId: "req-delete", + name: "Urgent", + }), + ).toMatchObject({ type: "workspace.label.delete.inspect.request" }); + }); + + test("requires the fields promised by each operation", () => { + expect(() => + SessionOutboundMessageSchema.parse({ + type: "workspace.label.assignment.set.response", + payload: { requestId: "req-assignment", label: { name: "Urgent", color: "red" } }, + }), + ).toThrow(); + expect(() => + SessionOutboundMessageSchema.parse({ + type: "workspace.label.delete.inspect.response", + payload: { requestId: "req-inspect" }, + }), + ).toThrow(); + expect(() => + SessionOutboundMessageSchema.parse({ + type: "workspace.label.update.response", + payload: { requestId: "req-edit", label: { name: "Priority", color: "sky" } }, + }), + ).toThrow(); + }); +}); diff --git a/packages/protocol/src/workspace-labels.ts b/packages/protocol/src/workspace-labels.ts new file mode 100644 index 000000000..0f72fbf18 --- /dev/null +++ b/packages/protocol/src/workspace-labels.ts @@ -0,0 +1,27 @@ +export const WORKSPACE_LABEL_COLORS = [ + "violet", + "sky", + "emerald", + "orange", + "pink", + "indigo", + "teal", + "red", + "amber", + "blue", +] as const; + +export type WorkspaceLabelColor = (typeof WORKSPACE_LABEL_COLORS)[number]; + +export interface WorkspaceLabelDefinition { + name: string; + color: WorkspaceLabelColor; +} + +export function normalizeWorkspaceLabelName(name: string): string { + return name.replace(/\s+/g, " ").trim(); +} + +export function workspaceLabelKey(name: string): string { + return normalizeWorkspaceLabelName(name).toLowerCase(); +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index b50342bfc..d2e093a07 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -118,6 +118,7 @@ export async function fanOutReconciledWorkspaceUpdates(input: { import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js"; +import { createWorkspaceLabelService } from "./workspace-labels/index.js"; import { createGitHubService } from "../services/github-service.js"; import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js"; import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; @@ -859,6 +860,10 @@ export async function createPaseoDaemon( path.join(config.paseoHome, "projects", "workspaces.json"), logger, ); + const workspaceLabelService = createWorkspaceLabelService({ + paseoHome: config.paseoHome, + workspaceRegistry, + }); const github = createGitHubService(); const workspaceGitService = new WorkspaceGitServiceImpl({ logger, @@ -927,6 +932,7 @@ export async function createPaseoDaemon( workspaceGitService, logger, }); + await workspaceLabelService.initialize(); logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped"); const teardownArchivedWorkspaceRuntime = (workspaceId: string): void => { scriptRuntimeStore.removeForWorkspace(workspaceId); @@ -1643,6 +1649,7 @@ export async function createPaseoDaemon( workspaceSetupRuntime, pluginRuntime, orchestrationSkills, + workspaceLabelService, ); pluginRuntime.bindPaseoSessionHost(wsServer); await pluginRuntime.start(); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index de0fd07ec..54e4a9e1e 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -24,6 +24,7 @@ import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentManagerEvent } from "./agent/agent-manager.js"; import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import { WorkspaceLabelError, type WorkspaceLabelService } from "./workspace-labels/index.js"; import { createPersistedProjectRecord } from "./workspace-registry.js"; import { deriveProjectKey } from "./project-key.js"; import type { SessionOptions } from "./session.js"; @@ -322,6 +323,7 @@ interface SessionForTestOptions { binaryMessages?: Uint8Array[]; pluginRuntime?: SessionOptions["pluginRuntime"]; orchestrationSkills?: SessionOptions["orchestrationSkills"]; + workspaceLabelService?: WorkspaceLabelService; } function createSessionForTest(options: SessionForTestOptions = {}): Session { @@ -396,6 +398,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { get: vi.fn(), list: vi.fn().mockResolvedValue([]), }, + workspaceLabelService: options.workspaceLabelService, scheduleService: asScheduleService(), checkoutDiffManager: asCheckoutDiffManager(checkoutDiffManager), github: asGitHubService(github), @@ -550,6 +553,174 @@ test("routes plugin requests and releases its owned catalog subscription on clea expect(releasePluginSubscription).toHaveBeenCalledOnce(); }); +describe("workspace label subscriptions", () => { + type LabelSubscription = Awaited>; + type LabelChange = Parameters[0]["onChange"]>[0]; + + function labelSubscription(requestId: string): LabelSubscription { + return { + snapshot: { + labels: [], + sync: { + mode: "snapshot", + generation: `generation-${requestId}`, + headSeq: 0, + removals: [], + }, + }, + unsubscribe: vi.fn(), + }; + } + + function labelListRequest(requestId: string) { + return { + type: "workspace.label.list.request" as const, + requestId, + subscribe: { subscriptionId: `subscription-${requestId}` }, + }; + } + + test("an overlapping request cannot reactivate its superseded subscription", async () => { + const first = deferred(); + const callbacks: Array<(change: LabelChange) => void> = []; + const service = { + subscribe: async (input: Parameters[0]) => { + callbacks.push(input.onChange); + return callbacks.length === 1 ? first.promise : labelSubscription("b"); + }, + } as unknown as WorkspaceLabelService; + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages, workspaceLabelService: service }); + + const requestA = session.handleMessage(labelListRequest("a")); + await Promise.resolve(); + await session.handleMessage(labelListRequest("b")); + const firstSubscription = labelSubscription("a"); + first.resolve(firstSubscription); + await requestA; + + expect(firstSubscription.unsubscribe).toHaveBeenCalledOnce(); + callbacks[0]?.({ + kind: "remove", + name: "Old", + generation: "generation-a", + seq: 1, + }); + callbacks[1]?.({ + kind: "remove", + name: "Current", + generation: "generation-b", + seq: 1, + }); + expect(messages.filter((message) => message.type === "workspace.label.update")).toEqual([ + expect.objectContaining({ payload: expect.objectContaining({ name: "Current" }) }), + ]); + }); + + test("a superseded failure cannot clear the current subscription or survive cleanup", async () => { + const first = deferred(); + const callbacks: Array<(change: LabelChange) => void> = []; + const current = labelSubscription("b"); + const service = { + subscribe: async (input: Parameters[0]) => { + callbacks.push(input.onChange); + return callbacks.length === 1 ? first.promise : current; + }, + } as unknown as WorkspaceLabelService; + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages, workspaceLabelService: service }); + + const requestA = session.handleMessage(labelListRequest("a")); + await Promise.resolve(); + await session.handleMessage(labelListRequest("b")); + first.reject(new Error("old request failed")); + await requestA; + + callbacks[1]?.({ + kind: "remove", + name: "Current", + generation: "generation-b", + seq: 1, + }); + expect(messages.filter((message) => message.type === "workspace.label.update")).toHaveLength(1); + await session.cleanup(); + expect(current.unsubscribe).toHaveBeenCalledOnce(); + callbacks[1]?.({ + kind: "remove", + name: "After cleanup", + generation: "generation-b", + seq: 2, + }); + expect(messages.filter((message) => message.type === "workspace.label.update")).toHaveLength(1); + }); +}); + +describe("workspace label editing", () => { + test("answers one edit with the label it produced and the assignments it rewrote", async () => { + const calls: unknown[] = []; + const service = { + update: async (input: unknown) => { + calls.push(input); + return { label: { name: "Priority", color: "sky" }, affectedWorkspaceCount: 3 }; + }, + } as unknown as WorkspaceLabelService; + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages, workspaceLabelService: service }); + + await session.handleMessage({ + type: "workspace.label.update.request", + requestId: "request-edit", + name: "Urgent", + newName: "Priority", + color: "sky", + }); + + expect(calls).toEqual([ + expect.objectContaining({ name: "Urgent", newName: "Priority", color: "sky" }), + ]); + expect(messages).toEqual([ + { + type: "workspace.label.update.response", + payload: { + requestId: "request-edit", + label: { name: "Priority", color: "sky" }, + affectedWorkspaceCount: 3, + }, + }, + ]); + }); + + test("reports a name collision as a coded error and emits no response", async () => { + const service = { + update: async () => { + throw new WorkspaceLabelError("label_name_taken", "A label with that name already exists"); + }, + } as unknown as WorkspaceLabelService; + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages, workspaceLabelService: service }); + + await session.handleMessage({ + type: "workspace.label.update.request", + requestId: "request-collision", + name: "Urgent", + newName: "Waiting", + color: "teal", + }); + + expect(messages).toEqual([ + { + type: "rpc_error", + payload: { + requestId: "request-collision", + requestType: "workspace.label.update.request", + code: "label_name_taken", + error: "A label with that name already exists", + }, + }, + ]); + }); +}); + describe("session authorization scopes", () => { test("routes named-agent validation through the session source", async () => { const messages: SessionOutboundMessage[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 11b6b4e8b..014d3e727 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -71,6 +71,11 @@ import { normalizeClientRestartRpcReason, } from "./lifecycle-reasons.js"; import { DirectorySyncService } from "./directory-sync/index.js"; +import { + WorkspaceLabelError, + WorkspaceLabelStorageUncertainError, + type WorkspaceLabelService, +} from "./workspace-labels/index.js"; import { AgentManager, AgentRunCancellationError } from "./agent/agent-manager.js"; import { buildTimelinePromptIndex } from "./agent/timeline-prompt-index.js"; @@ -449,6 +454,7 @@ export interface SessionOptions { projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; directorySync?: DirectorySyncService; + workspaceLabelService?: WorkspaceLabelService; filesystem?: SessionFileSystem; scheduleService: ScheduleService; checkoutDiffManager: CheckoutDiffManager; @@ -615,6 +621,23 @@ function describeRegistryTransition(record: ArchivedRecordSnapshot | null): Regi * It owns all state management, orchestration logic, and message processing. * Session has no knowledge of WebSockets - it only emits and receives messages. */ +function resolveWorkspaceLabelService( + service: WorkspaceLabelService | undefined, +): WorkspaceLabelService | null { + return service ?? null; +} + +function workspaceLabelErrorCode(error: unknown): string { + if ( + error instanceof WorkspaceLabelError || + error instanceof WorkspaceLabelStorageUncertainError || + error instanceof SessionRequestError + ) { + return error.code; + } + return "workspace_label_failed"; +} + export class Session { private readonly clientId: string; private scopes: readonly string[]; @@ -670,6 +693,12 @@ export class Session { private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; private readonly agentUpdates: AgentUpdatesService; private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; + private readonly workspaceLabelService: WorkspaceLabelService | null; + private workspaceLabelSubscription: { + owner: object; + id: string; + unsubscribe: () => void; + } | null = null; private projectSyncEnabled = false; private readonly workspaceUpdateTails = new Map>(); private clientActivity: { @@ -731,6 +760,7 @@ export class Session { projectRegistry, workspaceRegistry, directorySync, + workspaceLabelService, filesystem, scheduleService, checkoutDiffManager, @@ -803,6 +833,7 @@ export class Session { this.projectRegistry = projectRegistry; this.workspaceRegistry = workspaceRegistry; this.directorySync = resolveDirectorySync(directorySync); + this.workspaceLabelService = resolveWorkspaceLabelService(workspaceLabelService); this.filesystem = filesystem ?? nodeSessionFileSystem; this.github = github ?? createGitHubService(); this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; @@ -1884,6 +1915,7 @@ export class Session { this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? this.dispatchWorkspaceRecoveryMessage(msg) ?? + this.dispatchWorkspaceLabelMessage(msg) ?? this.dispatchWorkspaceAndProjectMessage(msg) ?? this.dispatchWorkspaceFileMessage(msg, source) ?? this.dispatchProviderMessage(msg) ?? @@ -2399,6 +2431,23 @@ export class Session { } } + private dispatchWorkspaceLabelMessage(msg: SessionInboundMessage): Promise | undefined { + switch (msg.type) { + case "workspace.label.list.request": + return this.handleWorkspaceLabelList(msg); + case "workspace.label.assignment.set.request": + return this.handleWorkspaceLabelAssignment(msg); + case "workspace.label.update.request": + return this.handleWorkspaceLabelUpdate(msg); + case "workspace.label.delete.request": + return this.handleWorkspaceLabelDelete(msg); + case "workspace.label.delete.inspect.request": + return this.handleWorkspaceLabelDeleteInspection(msg); + default: + return undefined; + } + } + private dispatchWorkspaceFileMessage( msg: SessionInboundMessage, source?: object, @@ -4677,6 +4726,7 @@ export class Session { name: resolveWorkspaceDisplayName(workspace), title: workspace.title, pinnedAt: workspace.pinnedAt, + ...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}), archivingAt: null, status: "done", statusEnteredAt: null, @@ -4768,6 +4818,9 @@ export class Session { }), title: result.workspace.title, pinnedAt: result.workspace.pinnedAt, + ...(result.workspace.labels && result.workspace.labels.length > 0 + ? { labels: result.workspace.labels } + : {}), archivingAt: null, status: "done", statusEnteredAt: result.workspace.createdAt, @@ -5494,6 +5547,157 @@ export class Session { } } + private requireWorkspaceLabels(): WorkspaceLabelService { + if (!this.workspaceLabelService) { + throw new SessionRequestError("workspace_labels_unavailable", "Workspace labels unavailable"); + } + return this.workspaceLabelService; + } + + private emitWorkspaceLabelError( + request: { requestId: string; type: string }, + error: unknown, + ): void { + this.emit({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + code: workspaceLabelErrorCode(error), + error: error instanceof Error ? error.message : "Workspace label operation failed", + }, + }); + } + + private async handleWorkspaceLabelList( + request: Extract, + ): Promise { + const owner = {}; + try { + this.workspaceLabelSubscription?.unsubscribe(); + this.workspaceLabelSubscription = { + owner, + id: request.subscribe.subscriptionId, + unsubscribe: () => undefined, + }; + const service = this.requireWorkspaceLabels(); + type LiveChange = Parameters[0]["onChange"]>[0]; + const pending: LiveChange[] = []; + let ready = false; + const emitChange = (change: LiveChange): void => { + this.emit({ + type: "workspace.label.update", + payload: + change.kind === "upsert" + ? { + kind: "upsert", + label: change.label, + ...(change.previousName ? { previousName: change.previousName } : {}), + generation: change.generation, + seq: change.seq, + } + : { + kind: "remove", + name: change.name, + generation: change.generation, + seq: change.seq, + }, + }); + }; + const subscription = await service.subscribe({ + cursor: request.sync, + onChange: (change) => { + if (this.workspaceLabelSubscription?.owner !== owner) return; + if (!ready) pending.push(change); + else emitChange(change); + }, + }); + const ownsSubscription = this.workspaceLabelSubscription?.owner === owner; + if (ownsSubscription) { + this.workspaceLabelSubscription = { + owner, + id: request.subscribe.subscriptionId, + unsubscribe: subscription.unsubscribe, + }; + } else { + subscription.unsubscribe(); + } + this.emit({ + type: "workspace.label.list.response", + payload: { requestId: request.requestId, ...subscription.snapshot }, + }); + ready = true; + if (ownsSubscription) { + for (const change of pending) { + if (change.seq > subscription.snapshot.sync.headSeq) emitChange(change); + } + } + } catch (error) { + if (this.workspaceLabelSubscription?.owner === owner) { + this.workspaceLabelSubscription = null; + } + this.emitWorkspaceLabelError(request, error); + } + } + + private async handleWorkspaceLabelAssignment( + request: Extract, + ): Promise { + try { + const result = await this.requireWorkspaceLabels().setAssignment(request); + this.emit({ + type: "workspace.label.assignment.set.response", + payload: { requestId: request.requestId, ...result }, + }); + } catch (error) { + this.emitWorkspaceLabelError(request, error); + } + } + + private async handleWorkspaceLabelUpdate( + request: Extract, + ): Promise { + try { + const result = await this.requireWorkspaceLabels().update(request); + this.emit({ + type: "workspace.label.update.response", + payload: { requestId: request.requestId, ...result }, + }); + } catch (error) { + this.emitWorkspaceLabelError(request, error); + } + } + + private async handleWorkspaceLabelDelete( + request: Extract, + ): Promise { + try { + const result = await this.requireWorkspaceLabels().delete(request.name); + this.emit({ + type: "workspace.label.delete.response", + payload: { requestId: request.requestId, ...result }, + }); + } catch (error) { + this.emitWorkspaceLabelError(request, error); + } + } + + private async handleWorkspaceLabelDeleteInspection( + request: Extract, + ): Promise { + try { + const affectedWorkspaceCount = await this.requireWorkspaceLabels().countAffectedWorkspaces( + request.name, + ); + this.emit({ + type: "workspace.label.delete.inspect.response", + payload: { requestId: request.requestId, affectedWorkspaceCount }, + }); + } catch (error) { + this.emitWorkspaceLabelError(request, error); + } + } + private async readAgentDirectorySync( request: Extract, ) { @@ -7243,6 +7447,8 @@ export class Session { this.unsubscribePluginChanges = null; this.unsubscribeWorkspaceMutations?.(); this.unsubscribeWorkspaceMutations = null; + this.workspaceLabelSubscription?.unsubscribe(); + this.workspaceLabelSubscription = null; this.agentUpdates.dispose(); await this.hubExecutionController?.cleanup(); if (this.unsubscribeTerminalWorkspaceContributionEvents) { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 285b42a7a..f59752a23 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -98,6 +98,7 @@ import { import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import { DirectorySyncService } from "./directory-sync/index.js"; +import type { WorkspaceLabelService } from "./workspace-labels/index.js"; import { APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS, ApplicationSocketLease, @@ -551,6 +552,7 @@ export class VoiceAssistantWebSocketServer { private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly workspaceLabelService: WorkspaceLabelService | null; private readonly scheduleService: ScheduleService; private readonly checkoutDiffManager: CheckoutDiffManager; private readonly github: ForgeService; @@ -649,6 +651,7 @@ export class VoiceAssistantWebSocketServer { workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(), pluginRuntime?: SessionOptions["pluginRuntime"], orchestrationSkills?: SessionOptions["orchestrationSkills"], + workspaceLabelService?: WorkspaceLabelService, ) { this.logger = logger.child({ module: "websocket-server" }); this.workspaceSetupRuntime = workspaceSetupRuntime; @@ -669,6 +672,7 @@ export class VoiceAssistantWebSocketServer { this.agentStorage = agentStorage; this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); + this.workspaceLabelService = workspaceLabelService ?? null; const requiredServices = requireWebSocketServices({ scheduleService, checkoutDiffManager, @@ -1428,6 +1432,7 @@ export class VoiceAssistantWebSocketServer { agentStorage: this.agentStorage, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, + workspaceLabelService: this.workspaceLabelService ?? undefined, directorySync: this.directorySync, scheduleService: this.scheduleService, checkoutDiffManager: this.checkoutDiffManager, @@ -1643,6 +1648,8 @@ export class VoiceAssistantWebSocketServer { features: { // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, + // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. + ...(this.workspaceLabelService ? { workspaceLabels: true } : {}), // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. providersSnapshot: true, // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. diff --git a/packages/server/src/server/workspace-labels/index.test.ts b/packages/server/src/server/workspace-labels/index.test.ts new file mode 100644 index 000000000..75a01f413 --- /dev/null +++ b/packages/server/src/server/workspace-labels/index.test.ts @@ -0,0 +1,1059 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { + createPersistedWorkspaceRecord, + FileBackedWorkspaceRegistry, +} from "../workspace-registry.js"; +import { createWorkspaceLabelService, type WorkspaceLabelService } from "./index.js"; +import { writeJsonFileAtomic } from "../atomic-file.js"; + +function transactionPhase(transaction: unknown): string | undefined { + return typeof transaction === "object" && transaction !== null && "phase" in transaction + ? String(transaction.phase) + : undefined; +} + +describe("workspace labels", () => { + let paseoHome: string; + let registry: FileBackedWorkspaceRegistry; + let labels: WorkspaceLabelService; + + beforeEach(async () => { + paseoHome = await mkdtemp(join(tmpdir(), "paseo-labels-")); + registry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + await registry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "wks_one", + projectId: "prj_one", + cwd: "/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + }), + ); + labels = createWorkspaceLabelService({ paseoHome, workspaceRegistry: registry }); + }); + + afterEach(async () => { + await rm(paseoHome, { recursive: true, force: true }); + }); + + test("normalizes identity, preserves the target definition, persists assignments, and stays silent on no-op", async () => { + const updates: unknown[] = []; + const initial = await labels.subscribe({ onChange: (change) => updates.push(change) }); + expect(initial.snapshot).toMatchObject({ labels: [], sync: { mode: "snapshot", headSeq: 0 } }); + + const assigned = await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: " Needs review ", color: "sky" }, + assigned: true, + }); + expect(assigned).toEqual({ + label: { name: "Needs review", color: "sky" }, + workspaceLabels: ["Needs review"], + }); + expect(updates).toHaveLength(1); + + const targetWins = await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "needs REVIEW", color: "red" }, + assigned: true, + }); + expect(targetWins.label).toEqual({ name: "Needs review", color: "sky" }); + expect(updates).toHaveLength(1); + + const reloaded = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + expect((await reloaded.get("wks_one"))?.labels).toEqual(["Needs review"]); + const reloadedLabels = createWorkspaceLabelService({ + paseoHome, + workspaceRegistry: reloaded, + }); + const persisted = await reloadedLabels.subscribe({ onChange: () => undefined }); + expect(persisted.snapshot.labels).toEqual([{ name: "Needs review", color: "sky" }]); + persisted.unsubscribe(); + initial.unsubscribe(); + }); + + test("catches up one catalog change and falls back to a coherent snapshot", async () => { + const initial = await labels.subscribe({ onChange: () => undefined }); + await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Blocked", color: "red" }, + assigned: true, + }); + initial.unsubscribe(); + + const caughtUp = await labels.subscribe({ + cursor: { + generation: initial.snapshot.sync.generation, + afterSeq: initial.snapshot.sync.headSeq, + }, + onChange: () => undefined, + }); + expect(caughtUp.snapshot).toMatchObject({ + labels: [{ name: "Blocked", color: "red" }], + sync: { mode: "changes", headSeq: 1 }, + }); + const current = await labels.subscribe({ + cursor: { + generation: caughtUp.snapshot.sync.generation, + afterSeq: caughtUp.snapshot.sync.headSeq, + }, + onChange: () => undefined, + }); + expect(current.snapshot).toMatchObject({ + labels: [], + sync: { mode: "changes", headSeq: 1, removals: [] }, + }); + current.unsubscribe(); + caughtUp.unsubscribe(); + + const expired = await labels.subscribe({ + cursor: { generation: "expired", afterSeq: 1 }, + onChange: () => undefined, + }); + expect(expired.snapshot).toMatchObject({ + labels: [{ name: "Blocked", color: "red" }], + sync: { mode: "snapshot", headSeq: 1 }, + }); + expired.unsubscribe(); + }); + + test("publishes only to active subscribers and emits no idle or no-op traffic", async () => { + const updates: unknown[] = []; + const subscription = await labels.subscribe({ onChange: (change) => updates.push(change) }); + await Promise.resolve(); + expect(updates).toEqual([]); + + await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Quiet", color: "sky" }, + assigned: false, + }); + expect(updates).toEqual([]); + + subscription.unsubscribe(); + await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Quiet", color: "sky" }, + assigned: true, + }); + expect(updates).toEqual([]); + }); + + test("compacts name cycles and a chained edit-delete into coherent catch-up changes", async () => { + await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "A", color: "red" }, + assigned: true, + }); + const checkpoint = await labels.subscribe({ onChange: () => undefined }); + + await labels.update({ name: "A", newName: "B" }); + await labels.update({ name: "B", newName: "A" }); + const cycle = await labels.subscribe({ + cursor: { + generation: checkpoint.snapshot.sync.generation, + afterSeq: checkpoint.snapshot.sync.headSeq, + }, + onChange: () => undefined, + }); + expect(cycle.snapshot).toMatchObject({ + labels: [{ name: "A", color: "red" }], + sync: { mode: "changes", removals: [] }, + }); + + await labels.update({ name: "A", newName: "B" }); + await labels.update({ name: "B", newName: "C" }); + await labels.delete("C"); + const deleted = await labels.subscribe({ + cursor: { + generation: cycle.snapshot.sync.generation, + afterSeq: cycle.snapshot.sync.headSeq, + }, + onChange: () => undefined, + }); + expect(deleted.snapshot).toMatchObject({ + labels: [], + sync: { mode: "changes", removals: [{ name: "A" }] }, + }); + + deleted.unsubscribe(); + cycle.unsubscribe(); + checkpoint.unsubscribe(); + }); + + test("edits and deletes one host catalog while rewriting assignments", async () => { + await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Blocked", color: "red" }, + assigned: true, + }); + expect(await labels.update({ name: "blocked", newName: "Waiting" })).toMatchObject({ + label: { name: "Waiting", color: "red" }, + affectedWorkspaceCount: 1, + }); + await expect(labels.update({ name: "waiting", newName: "" })).rejects.toMatchObject({ + code: "label_name_empty", + }); + expect(await labels.update({ name: "WAITING", color: "amber" })).toEqual({ + label: { name: "Waiting", color: "amber" }, + affectedWorkspaceCount: 0, + }); + expect(await labels.delete("waiting")).toEqual({ affectedWorkspaceCount: 1 }); + expect((await registry.get("wks_one"))?.labels).toBeUndefined(); + }); + + test("applies a name and a colour in one commit, or neither", async () => { + const changes: unknown[] = []; + const subscription = await labels.subscribe({ onChange: (change) => changes.push(change) }); + await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Blocked", color: "red" }, + assigned: true, + }); + await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Waiting", color: "amber" }, + assigned: true, + }); + changes.length = 0; + + expect(await labels.update({ name: "blocked", newName: "Urgent", color: "sky" })).toEqual({ + label: { name: "Urgent", color: "sky" }, + affectedWorkspaceCount: 1, + }); + // One publication for one edit — a client never sees the name land without the colour. + expect(changes).toMatchObject([ + { kind: "upsert", label: { name: "Urgent", color: "sky" }, previousName: "Blocked" }, + ]); + expect((await registry.get("wks_one"))?.labels).toEqual(["Urgent", "Waiting"]); + + // A collision applies neither field: the colour in the same request is refused with the name. + await expect( + labels.update({ name: "Urgent", newName: "waiting", color: "teal" }), + ).rejects.toMatchObject({ code: "label_name_taken" }); + expect(changes).toHaveLength(1); + expect((await labels.subscribe({ onChange: () => undefined })).snapshot.labels).toEqual([ + { name: "Urgent", color: "sky" }, + { name: "Waiting", color: "amber" }, + ]); + + // A label is not colliding with itself, so case is editable. + expect(await labels.update({ name: "Urgent", newName: "URGENT" })).toMatchObject({ + label: { name: "URGENT", color: "sky" }, + affectedWorkspaceCount: 1, + }); + expect((await registry.get("wks_one"))?.labels).toEqual(["URGENT", "Waiting"]); + + // An edit that changes nothing changes nothing, quietly. + changes.length = 0; + expect(await labels.update({ name: "urgent", newName: "URGENT", color: "sky" })).toEqual({ + label: { name: "URGENT", color: "sky" }, + affectedWorkspaceCount: 0, + }); + expect(changes).toEqual([]); + subscription.unsubscribe(); + }); + + test("counts the same active and archived records that delete rewrites", async () => { + await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Blocked", color: "red" }, + assigned: true, + }); + await registry.archive("wks_one", "2026-08-14T01:00:00.000Z"); + + expect(await labels.countAffectedWorkspaces("blocked")).toBe(1); + expect(await labels.delete("Blocked")).toEqual({ affectedWorkspaceCount: 1 }); + expect((await registry.get("wks_one"))?.labels).toBeUndefined(); + }); + + test("rejects name collisions without changing catalog or assignments", async () => { + await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Blocked", color: "red" }, + assigned: true, + }); + await labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Waiting", color: "amber" }, + assigned: true, + }); + + await expect(labels.update({ name: "Blocked", newName: "waiting" })).rejects.toMatchObject({ + code: "label_name_taken", + }); + expect((await registry.get("wks_one"))?.labels).toEqual(["Blocked", "Waiting"]); + const snapshot = await labels.subscribe({ onChange: () => undefined }); + expect(snapshot.snapshot.labels).toEqual([ + { name: "Blocked", color: "red" }, + { name: "Waiting", color: "amber" }, + ]); + snapshot.unsubscribe(); + }); + + test("blocks when assignment persistence and rollback both fail", async () => { + let failWrites = false; + const failingRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "failing-workspaces.json"), + createTestLogger(), + { + writeRecords: async (filePath, records) => { + if (failWrites) throw new Error("disk full"); + await writeJsonFileAtomic(filePath, records); + }, + }, + ); + await failingRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "wks_failing", + projectId: "prj_one", + cwd: "/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + }), + ); + const failingLabels = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "failing"), + workspaceRegistry: failingRegistry, + }); + failWrites = true; + + await expect( + failingLabels.setAssignment({ + workspaceId: "wks_failing", + label: { name: "Urgent", color: "red" }, + assigned: true, + }), + ).rejects.toMatchObject({ code: "workspace_label_storage_uncertain" }); + + const snapshot = await failingLabels.subscribe({ onChange: () => undefined }); + expect(snapshot.snapshot).toMatchObject({ labels: [], sync: { headSeq: 0 } }); + expect((await failingRegistry.get("wks_failing"))?.labels).toBeUndefined(); + snapshot.unsubscribe(); + }); + + test("restores workspace state when catalog persistence fails and keeps the catalog cache authoritative", async () => { + let failCatalog = true; + const failingLabels = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "catalog-failure"), + workspaceRegistry: registry, + writeCatalog: async (filePath, definitions) => { + if (failCatalog && definitions.length > 0) throw new Error("catalog disk full"); + await writeJsonFileAtomic(filePath, definitions); + }, + }); + + await expect( + failingLabels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }), + ).rejects.toThrow("catalog disk full"); + expect((await registry.get("wks_one"))?.labels).toBeUndefined(); + const failedSnapshot = await failingLabels.subscribe({ onChange: () => undefined }); + expect(failedSnapshot.snapshot).toMatchObject({ labels: [], sync: { headSeq: 0 } }); + + failCatalog = false; + await failingLabels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + expect((await registry.get("wks_one"))?.labels).toEqual(["Urgent"]); + failedSnapshot.unsubscribe(); + }); + + test("restores rename and delete assignment rewrites when catalog persistence fails", async () => { + let failingCatalog: "priority" | "empty" | null = null; + const failingLabels = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "rewrite-catalog-failure"), + workspaceRegistry: registry, + writeCatalog: async (filePath, definitions) => { + if ( + (failingCatalog === "priority" && + definitions.some((label) => label.name === "Priority")) || + (failingCatalog === "empty" && definitions.length === 0) + ) { + throw new Error("catalog unavailable"); + } + await writeJsonFileAtomic(filePath, definitions); + }, + }); + await failingLabels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + failingCatalog = "priority"; + + await expect(failingLabels.update({ name: "Urgent", newName: "Priority" })).rejects.toThrow( + "catalog unavailable", + ); + expect((await registry.get("wks_one"))?.labels).toEqual(["Urgent"]); + failingCatalog = "empty"; + await expect(failingLabels.delete("Urgent")).rejects.toThrow("catalog unavailable"); + expect((await registry.get("wks_one"))?.labels).toEqual(["Urgent"]); + + const snapshot = await failingLabels.subscribe({ onChange: () => undefined }); + expect(snapshot.snapshot).toMatchObject({ + labels: [{ name: "Urgent", color: "red" }], + sync: { headSeq: 1 }, + }); + snapshot.unsubscribe(); + }); + + test("rolls back an interrupted compound rename after restart without publishing transient changes", async () => { + let workspaceWrite = 0; + let interruptWorkspaceWrite = false; + const interruptedRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "interrupted-workspaces.json"), + createTestLogger(), + { + writeRecords: async (filePath, records) => { + workspaceWrite += 1; + if (interruptWorkspaceWrite && workspaceWrite > 2) { + throw new Error("process interrupted"); + } + await writeJsonFileAtomic(filePath, records); + }, + }, + ); + await interruptedRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "wks_interrupted", + projectId: "prj_one", + cwd: "/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + }), + ); + const interrupted = createWorkspaceLabelService({ + paseoHome, + workspaceRegistry: interruptedRegistry, + writeCatalog: writeJsonFileAtomic, + }); + await interrupted.setAssignment({ + workspaceId: "wks_interrupted", + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + const workspacePublications: unknown[] = []; + const catalogPublications: unknown[] = []; + interruptedRegistry.subscribeToMutations((mutation) => workspacePublications.push(mutation)); + const subscription = await interrupted.subscribe({ + onChange: (change) => catalogPublications.push(change), + }); + interruptWorkspaceWrite = true; + + await expect(interrupted.update({ name: "Urgent", newName: "Priority" })).rejects.toThrow(); + expect(workspacePublications).toEqual([]); + expect(catalogPublications).toEqual([]); + + const recoveredRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "interrupted-workspaces.json"), + createTestLogger(), + ); + const recovered = createWorkspaceLabelService({ + paseoHome, + workspaceRegistry: recoveredRegistry, + }); + await recovered.initialize(); + expect((await recoveredRegistry.get("wks_interrupted"))?.labels).toEqual(["Urgent"]); + const recoveredSnapshot = await recovered.subscribe({ onChange: () => undefined }); + expect(recoveredSnapshot.snapshot.labels).toEqual([{ name: "Urgent", color: "red" }]); + recoveredSnapshot.unsubscribe(); + subscription.unsubscribe(); + }); + + test("aborts a pre-commit workspace write failure without publishing either surface", async () => { + let failNextWorkspaceWrite = false; + const recoveringRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "recovering-workspaces.json"), + createTestLogger(), + { + writeRecords: async (filePath, records) => { + if (failNextWorkspaceWrite) { + failNextWorkspaceWrite = false; + throw new Error("one interrupted write"); + } + await writeJsonFileAtomic(filePath, records); + }, + }, + ); + await recoveringRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "wks_recovering", + projectId: "prj_one", + cwd: "/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + }), + ); + const recovering = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "recovering"), + workspaceRegistry: recoveringRegistry, + }); + await recovering.setAssignment({ + workspaceId: "wks_recovering", + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + const workspacePublications: unknown[] = []; + const catalogPublications: unknown[] = []; + recoveringRegistry.subscribeToMutations((mutation) => workspacePublications.push(mutation)); + const subscription = await recovering.subscribe({ + onChange: (change) => catalogPublications.push(change), + }); + + failNextWorkspaceWrite = true; + await expect(recovering.update({ name: "Urgent", newName: "Priority" })).rejects.toThrow( + "one interrupted write", + ); + + expect((await recoveringRegistry.get("wks_recovering"))?.labels).toEqual(["Urgent"]); + expect(workspacePublications).toEqual([]); + expect(catalogPublications).toEqual([]); + subscription.unsubscribe(); + }); + + test("treats the journal phase rewrite as the commit point", async () => { + let failCommitMarker = false; + const markerLabels = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "commit-marker"), + workspaceRegistry: registry, + writeTransaction: async (filePath, transaction) => { + if (failCommitMarker && transactionPhase(transaction) === "committed") { + throw new Error("commit marker unavailable"); + } + await writeJsonFileAtomic(filePath, transaction); + }, + }); + await markerLabels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + const workspacePublications: unknown[] = []; + const catalogPublications: unknown[] = []; + registry.subscribeToMutations((mutation) => workspacePublications.push(mutation)); + const subscription = await markerLabels.subscribe({ + onChange: (change) => catalogPublications.push(change), + }); + + failCommitMarker = true; + await expect(markerLabels.update({ name: "Urgent", newName: "Priority" })).rejects.toThrow( + "commit marker unavailable", + ); + expect((await registry.get("wks_one"))?.labels).toEqual(["Urgent"]); + expect(workspacePublications).toEqual([]); + expect(catalogPublications).toEqual([]); + + const restartedRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + const restarted = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "commit-marker"), + workspaceRegistry: restartedRegistry, + }); + await restarted.initialize(); + expect((await restartedRegistry.get("wks_one"))?.labels).toEqual(["Urgent"]); + expect((await restarted.subscribe({ onChange: () => undefined })).snapshot.labels).toEqual([ + { name: "Urgent", color: "red" }, + ]); + subscription.unsubscribe(); + }); + + test("does not leave replayable intent when prepared-journal persistence fails", async () => { + const workspacePublications: unknown[] = []; + const catalogPublications: unknown[] = []; + registry.subscribeToMutations((mutation) => workspacePublications.push(mutation)); + const preparedLabels = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "prepared-write"), + workspaceRegistry: registry, + writeTransaction: async () => { + throw new Error("journal unavailable"); + }, + }); + const subscription = await preparedLabels.subscribe({ + onChange: (change) => catalogPublications.push(change), + }); + + await expect( + preparedLabels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }), + ).rejects.toThrow("journal unavailable"); + expect((await registry.get("wks_one"))?.labels).toBeUndefined(); + expect(workspacePublications).toEqual([]); + expect(catalogPublications).toEqual([]); + + const restartedRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + const restarted = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "prepared-write"), + workspaceRegistry: restartedRegistry, + }); + await restarted.initialize(); + expect((await restartedRegistry.get("wks_one"))?.labels).toBeUndefined(); + expect((await restarted.subscribe({ onChange: () => undefined })).snapshot.labels).toEqual([]); + subscription.unsubscribe(); + }); + + test("rolls back a durable prepared marker whose acknowledgement is lost before allowing registry writes", async () => { + let losePreparedAcknowledgement = false; + const preparedLabels = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "prepared-lost-ack"), + workspaceRegistry: registry, + writeTransaction: async (filePath, transaction) => { + await writeJsonFileAtomic(filePath, transaction); + if (losePreparedAcknowledgement && transactionPhase(transaction) === "prepared") { + throw new Error("lost prepared acknowledgement"); + } + }, + }); + await preparedLabels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + const workspacePublications: unknown[] = []; + const catalogPublications: unknown[] = []; + registry.subscribeToMutations((mutation) => workspacePublications.push(mutation)); + const subscription = await preparedLabels.subscribe({ + onChange: (change) => catalogPublications.push(change), + }); + + losePreparedAcknowledgement = true; + await expect(preparedLabels.update({ name: "Urgent", newName: "Priority" })).rejects.toThrow( + "lost prepared acknowledgement", + ); + expect((await registry.get("wks_one"))?.labels).toEqual(["Urgent"]); + expect(workspacePublications).toEqual([]); + expect(catalogPublications).toEqual([]); + + losePreparedAcknowledgement = false; + const archivedAt = "2099-01-01T00:00:00.000Z"; + await registry.archive("wks_one", archivedAt); + expect(workspacePublications).toHaveLength(1); + + const restartedRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + const restarted = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "prepared-lost-ack"), + workspaceRegistry: restartedRegistry, + }); + await restarted.initialize(); + expect(await restartedRegistry.get("wks_one")).toMatchObject({ + labels: ["Urgent"], + archivedAt, + updatedAt: archivedAt, + }); + expect((await restarted.subscribe({ onChange: () => undefined })).snapshot.labels).toEqual([ + { name: "Urgent", color: "red" }, + ]); + subscription.unsubscribe(); + }); + + test("rolls back a durable catalog write whose acknowledgement is lost before allowing registry writes", async () => { + let loseCatalogAcknowledgement = false; + const catalogLabels = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "catalog-lost-ack"), + workspaceRegistry: registry, + writeCatalog: async (filePath, definitions) => { + await writeJsonFileAtomic(filePath, definitions); + if ( + loseCatalogAcknowledgement && + definitions.some((definition) => definition.name === "Priority") + ) { + throw new Error("lost catalog acknowledgement"); + } + }, + }); + await catalogLabels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + const workspacePublications: unknown[] = []; + const catalogPublications: unknown[] = []; + registry.subscribeToMutations((mutation) => workspacePublications.push(mutation)); + const subscription = await catalogLabels.subscribe({ + onChange: (change) => catalogPublications.push(change), + }); + + loseCatalogAcknowledgement = true; + await expect(catalogLabels.update({ name: "Urgent", newName: "Priority" })).rejects.toThrow( + "lost catalog acknowledgement", + ); + expect((await registry.get("wks_one"))?.labels).toEqual(["Urgent"]); + expect(workspacePublications).toEqual([]); + expect(catalogPublications).toEqual([]); + + loseCatalogAcknowledgement = false; + const archivedAt = "2099-01-02T00:00:00.000Z"; + await registry.archive("wks_one", archivedAt); + const restartedRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + const restarted = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "catalog-lost-ack"), + workspaceRegistry: restartedRegistry, + }); + await restarted.initialize(); + expect(await restartedRegistry.get("wks_one")).toMatchObject({ + labels: ["Urgent"], + archivedAt, + }); + expect((await restarted.subscribe({ onChange: () => undefined })).snapshot.labels).toEqual([ + { name: "Urgent", color: "red" }, + ]); + subscription.unsubscribe(); + }); + + test("rolls back a durable workspace write whose acknowledgement is lost before allowing later writes", async () => { + let loseWorkspaceAcknowledgement = false; + const lostAckRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "lost-ack-workspaces.json"), + createTestLogger(), + { + writeRecords: async (filePath, records) => { + await writeJsonFileAtomic(filePath, records); + if (loseWorkspaceAcknowledgement) { + loseWorkspaceAcknowledgement = false; + throw new Error("lost workspace acknowledgement"); + } + }, + }, + ); + await lostAckRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "wks_lost_ack", + projectId: "prj_one", + cwd: "/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + }), + ); + const workspaceLabels = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "workspace-lost-ack"), + workspaceRegistry: lostAckRegistry, + }); + await workspaceLabels.setAssignment({ + workspaceId: "wks_lost_ack", + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + const workspacePublications: unknown[] = []; + const catalogPublications: unknown[] = []; + lostAckRegistry.subscribeToMutations((mutation) => workspacePublications.push(mutation)); + const subscription = await workspaceLabels.subscribe({ + onChange: (change) => catalogPublications.push(change), + }); + + loseWorkspaceAcknowledgement = true; + await expect(workspaceLabels.update({ name: "Urgent", newName: "Priority" })).rejects.toThrow( + "lost workspace acknowledgement", + ); + expect((await lostAckRegistry.get("wks_lost_ack"))?.labels).toEqual(["Urgent"]); + expect(workspacePublications).toEqual([]); + expect(catalogPublications).toEqual([]); + + const archivedAt = "2099-01-03T00:00:00.000Z"; + await lostAckRegistry.archive("wks_lost_ack", archivedAt); + const restartedRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "lost-ack-workspaces.json"), + createTestLogger(), + ); + const restarted = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "workspace-lost-ack"), + workspaceRegistry: restartedRegistry, + }); + await restarted.initialize(); + expect(await restartedRegistry.get("wks_lost_ack")).toMatchObject({ + labels: ["Urgent"], + archivedAt, + }); + expect((await restarted.subscribe({ onChange: () => undefined })).snapshot.labels).toEqual([ + { name: "Urgent", color: "red" }, + ]); + subscription.unsubscribe(); + }); + + test("reports an uncertain outcome when the committed marker is durable before its write rejects", async () => { + let rejectAfterCommittedWrite = false; + const uncertainLabels = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "uncertain-commit-marker"), + workspaceRegistry: registry, + writeTransaction: async (filePath, transaction) => { + await writeJsonFileAtomic(filePath, transaction); + if (rejectAfterCommittedWrite && transactionPhase(transaction) === "committed") { + throw new Error("lost commit acknowledgement"); + } + }, + }); + await uncertainLabels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + const workspacePublications: unknown[] = []; + const catalogPublications: unknown[] = []; + registry.subscribeToMutations((mutation) => workspacePublications.push(mutation)); + const subscription = await uncertainLabels.subscribe({ + onChange: (change) => catalogPublications.push(change), + }); + + rejectAfterCommittedWrite = true; + await expect( + uncertainLabels.update({ name: "Urgent", newName: "Priority" }), + ).rejects.toMatchObject({ code: "workspace_label_storage_uncertain" }); + expect((await registry.get("wks_one"))?.labels).toEqual(["Urgent"]); + expect(workspacePublications).toEqual([]); + expect(catalogPublications).toEqual([]); + rejectAfterCommittedWrite = false; + await expect(registry.archive("wks_one", "2099-01-04T00:00:00.000Z")).rejects.toThrow( + "blocked until daemon restart", + ); + await expect(uncertainLabels.update({ name: "Priority", color: "sky" })).rejects.toMatchObject({ + code: "workspace_label_storage_uncertain", + }); + expect(workspacePublications).toEqual([]); + expect(catalogPublications).toEqual([]); + + const restartedRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + const restarted = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "uncertain-commit-marker"), + workspaceRegistry: restartedRegistry, + }); + await restarted.initialize(); + expect((await restartedRegistry.get("wks_one"))?.labels).toEqual(["Priority"]); + expect((await restartedRegistry.get("wks_one"))?.archivedAt).toBeNull(); + expect((await restarted.subscribe({ onChange: () => undefined })).snapshot.labels).toEqual([ + { name: "Priority", color: "red" }, + ]); + subscription.unsubscribe(); + }); + + test("keeps a rejected mutation aborted when prepared-journal cleanup also fails", async () => { + let failAfterCatalog = false; + const cleanupLabels = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "prepared-cleanup"), + workspaceRegistry: registry, + writeCatalog: async (filePath, definitions) => { + if (failAfterCatalog && definitions.some((label) => label.name === "Priority")) { + throw new Error("catalog unavailable"); + } + await writeJsonFileAtomic(filePath, definitions); + }, + removeTransaction: async () => { + throw new Error("journal cleanup unavailable"); + }, + }); + await cleanupLabels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + failAfterCatalog = true; + + await expect( + cleanupLabels.update({ name: "Urgent", newName: "Priority" }), + ).rejects.toMatchObject({ code: "workspace_label_storage_uncertain" }); + expect((await registry.get("wks_one"))?.labels).toEqual(["Urgent"]); + + const restartedRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + const restarted = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "prepared-cleanup"), + workspaceRegistry: restartedRegistry, + }); + await restarted.initialize(); + expect((await restartedRegistry.get("wks_one"))?.labels).toEqual(["Urgent"]); + expect((await restarted.subscribe({ onChange: () => undefined })).snapshot.labels).toEqual([ + { name: "Urgent", color: "red" }, + ]); + }); + + test("reports success after the commit point even when committed-journal cleanup fails", async () => { + let failCleanup = false; + const cleanupLabels = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "committed-cleanup"), + workspaceRegistry: registry, + removeTransaction: async (filePath) => { + if (failCleanup) throw new Error("journal cleanup unavailable"); + await rm(filePath); + }, + }); + await cleanupLabels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }); + failCleanup = true; + + await expect(cleanupLabels.update({ name: "Urgent", newName: "Priority" })).resolves.toEqual({ + label: { name: "Priority", color: "red" }, + affectedWorkspaceCount: 1, + }); + const archivedAt = "2099-01-01T00:00:00.000Z"; + await registry.archive("wks_one", archivedAt); + + const restartedRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + const restarted = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "committed-cleanup"), + workspaceRegistry: restartedRegistry, + removeTransaction: async () => { + throw new Error("journal cleanup still unavailable"); + }, + }); + await restarted.initialize(); + expect(await restartedRegistry.get("wks_one")).toMatchObject({ + labels: ["Priority"], + archivedAt, + updatedAt: archivedAt, + }); + expect((await restarted.subscribe({ onChange: () => undefined })).snapshot.labels).toEqual([ + { name: "Priority", color: "red" }, + ]); + + const repeatedRegistry = new FileBackedWorkspaceRegistry( + join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + const repeated = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "committed-cleanup"), + workspaceRegistry: repeatedRegistry, + removeTransaction: async () => { + throw new Error("journal cleanup still unavailable"); + }, + }); + await repeated.initialize(); + expect(await repeatedRegistry.get("wks_one")).toMatchObject({ + labels: ["Priority"], + archivedAt, + updatedAt: archivedAt, + }); + }); + + test("does not reject or roll back durable mutations when a workspace listener fails", async () => { + registry.subscribeToMutations(async () => { + throw new Error("subscriber failed"); + }); + + await expect( + labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }), + ).resolves.toMatchObject({ workspaceLabels: ["Urgent"] }); + expect((await registry.get("wks_one"))?.labels).toEqual(["Urgent"]); + }); + + test("does not reject a durable catalog mutation when a subscriber fails", async () => { + const subscription = await labels.subscribe({ + onChange: () => { + throw new Error("subscriber failed"); + }, + }); + + await expect( + labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }), + ).resolves.toMatchObject({ workspaceLabels: ["Urgent"] }); + expect((await registry.get("wks_one"))?.labels).toEqual(["Urgent"]); + subscription.unsubscribe(); + }); + + test("preserves concurrent workspace fields while assigning a label", async () => { + await Promise.all([ + labels.setAssignment({ + workspaceId: "wks_one", + label: { name: "Urgent", color: "red" }, + assigned: true, + }), + registry.update("wks_one", (workspace) => ({ ...workspace, title: "Concurrent title" })), + ]); + + expect(await registry.get("wks_one")).toMatchObject({ + title: "Concurrent title", + labels: ["Urgent"], + }); + }); + + test("expires a bounded journal to a coherent snapshot", async () => { + const bounded = createWorkspaceLabelService({ + paseoHome: join(paseoHome, "bounded"), + workspaceRegistry: registry, + journalLimit: 1, + }); + const checkpoint = await bounded.subscribe({ onChange: () => undefined }); + await bounded.setAssignment({ + workspaceId: "wks_one", + label: { name: "One", color: "red" }, + assigned: true, + }); + await bounded.setAssignment({ + workspaceId: "wks_one", + label: { name: "Two", color: "sky" }, + assigned: true, + }); + + const expired = await bounded.subscribe({ + cursor: { + generation: checkpoint.snapshot.sync.generation, + afterSeq: checkpoint.snapshot.sync.headSeq, + }, + onChange: () => undefined, + }); + expect(expired.snapshot).toMatchObject({ + labels: [ + { name: "One", color: "red" }, + { name: "Two", color: "sky" }, + ], + sync: { mode: "snapshot", headSeq: 2 }, + }); + expired.unsubscribe(); + checkpoint.unsubscribe(); + }); +}); diff --git a/packages/server/src/server/workspace-labels/index.ts b/packages/server/src/server/workspace-labels/index.ts new file mode 100644 index 000000000..46173b458 --- /dev/null +++ b/packages/server/src/server/workspace-labels/index.ts @@ -0,0 +1,30 @@ +import { join } from "node:path"; +import type { FileBackedWorkspaceRegistry } from "../workspace-registry.js"; +import { WorkspaceLabelCatalogStore } from "./internal/catalog-store.js"; +import { WorkspaceLabelService } from "./internal/service.js"; +import type { WorkspaceLabelDefinition } from "@getpaseo/protocol/workspace-labels"; +import { WorkspaceLabelSequence } from "./internal/sequence.js"; + +export { WorkspaceLabelError, WorkspaceLabelService } from "./internal/service.js"; +export { WorkspaceLabelStorageUncertainError } from "./internal/catalog-store.js"; + +export function createWorkspaceLabelService(input: { + paseoHome: string; + workspaceRegistry: FileBackedWorkspaceRegistry; + writeCatalog?: (filePath: string, labels: readonly WorkspaceLabelDefinition[]) => Promise; + writeTransaction?: (filePath: string, transaction: unknown) => Promise; + removeTransaction?: (filePath: string) => Promise; + journalLimit?: number; +}): WorkspaceLabelService { + return new WorkspaceLabelService( + new WorkspaceLabelCatalogStore( + join(input.paseoHome, "projects", "workspace-labels.json"), + join(input.paseoHome, "projects", "workspace-labels.transaction.json"), + input.workspaceRegistry, + input.writeCatalog, + input.writeTransaction, + input.removeTransaction, + ), + new WorkspaceLabelSequence(input.journalLimit), + ); +} diff --git a/packages/server/src/server/workspace-labels/internal/catalog-store.ts b/packages/server/src/server/workspace-labels/internal/catalog-store.ts new file mode 100644 index 000000000..f07c5fa68 --- /dev/null +++ b/packages/server/src/server/workspace-labels/internal/catalog-store.ts @@ -0,0 +1,259 @@ +import { promises as fs } from "node:fs"; +import { z } from "zod"; +import type { WorkspaceLabelDefinition } from "@getpaseo/protocol/workspace-labels"; +import { WorkspaceLabelDefinitionSchema } from "@getpaseo/protocol/messages"; +import { writeJsonFileAtomic } from "../../atomic-file.js"; +import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; + +interface WorkspaceLabelCompoundRegistry { + blockAllMutationsUntilRestart(): void; + commitWorkspaceLabelMutation(input: { + stage: (records: ReadonlyMap) => { + updates: readonly PersistedWorkspaceRecord[]; + result: TResult; + forcePersist: boolean; + }; + beforeWorkspaceWrite: (records: readonly PersistedWorkspaceRecord[]) => Promise; + afterWorkspaceWrite: () => Promise; + afterCommit: () => void; + publish?: boolean; + }): Promise; +} + +interface WorkspaceLabelMutation { + labels: WorkspaceLabelDefinition[]; + workspaceUpdates: PersistedWorkspaceRecord[]; + result: TResult; +} + +const WorkspaceLabelWorkspaceStateSchema = z.object({ + workspaceId: z.string(), + labels: z.array(z.string()).optional(), + updatedAt: z.string(), +}); +const WorkspaceLabelTransactionSchema = z.object({ + phase: z.enum(["prepared", "committed"]), + beforeLabels: z.array(WorkspaceLabelDefinitionSchema), + afterLabels: z.array(WorkspaceLabelDefinitionSchema), + beforeWorkspaces: z.array(WorkspaceLabelWorkspaceStateSchema), + afterWorkspaces: z.array(WorkspaceLabelWorkspaceStateSchema), +}); + +type WorkspaceLabelTransaction = z.infer; + +export class WorkspaceLabelStorageUncertainError extends Error { + readonly code = "workspace_label_storage_uncertain"; + + constructor() { + super("Workspace label storage outcome is uncertain; restart the daemon before retrying"); + this.name = "WorkspaceLabelStorageUncertainError"; + } +} + +export class WorkspaceLabelCatalogStore { + private loaded = false; + private initializing: Promise | null = null; + private labels: WorkspaceLabelDefinition[] = []; + private blocked = false; + + constructor( + private readonly filePath: string, + private readonly transactionPath: string, + private readonly workspaces: WorkspaceLabelCompoundRegistry, + private readonly writeCatalog: ( + filePath: string, + labels: readonly WorkspaceLabelDefinition[], + ) => Promise = writeJsonFileAtomic, + private readonly writeTransaction: ( + filePath: string, + transaction: unknown, + ) => Promise = writeJsonFileAtomic, + private readonly removeTransaction: (filePath: string) => Promise = fs.rm, + ) {} + + async initialize(): Promise { + if (this.loaded) return; + if (!this.initializing) { + this.initializing = this.loadAndRecover().finally(() => { + this.initializing = null; + }); + } + await this.initializing; + } + + async list(): Promise { + await this.initialize(); + return this.labels.map((label) => ({ ...label })); + } + + async commit( + planner: ( + labels: readonly WorkspaceLabelDefinition[], + workspaces: ReadonlyMap, + ) => WorkspaceLabelMutation, + ): Promise { + await this.initialize(); + if (this.blocked) throw new WorkspaceLabelStorageUncertainError(); + + let mutation!: WorkspaceLabelMutation; + let transaction: WorkspaceLabelTransaction | null = null; + const result = await this.workspaces + .commitWorkspaceLabelMutation({ + stage: (workspaces) => { + mutation = planner(this.labels, workspaces); + transaction = transactionFor(this.labels, mutation, workspaces); + return { + updates: mutation.workspaceUpdates, + result: mutation.result, + forcePersist: + mutation.workspaceUpdates.length > 0 || !catalogsEqual(this.labels, mutation.labels), + }; + }, + beforeWorkspaceWrite: async () => { + if (!transaction) throw new Error("Workspace label transaction was not staged"); + await this.writeTransaction(this.transactionPath, transaction); + await this.writeCatalog(this.filePath, transaction.afterLabels); + }, + afterWorkspaceWrite: async () => { + if (!transaction) throw new Error("Workspace label transaction was not staged"); + transaction = { ...transaction, phase: "committed" }; + await this.writeTransaction(this.transactionPath, transaction); + }, + afterCommit: () => { + this.labels = [...mutation.labels]; + }, + }) + .catch(async (error: unknown) => { + await this.resolveFailedCommit(error); + }); + + await this.removeTransaction(this.transactionPath).catch(() => undefined); + return result as TResult; + } + + private async resolveFailedCommit(error: unknown): Promise { + let durable: WorkspaceLabelTransaction | null; + try { + durable = await this.readTransaction(); + } catch { + this.blockUntilRestart(); + } + if (!durable) throw error; + if (durable.phase === "committed") { + this.blockUntilRestart(); + } + try { + await this.recover(durable); + } catch { + this.blockUntilRestart(); + } + throw error; + } + + private blockUntilRestart(): never { + this.blocked = true; + this.workspaces.blockAllMutationsUntilRestart(); + throw new WorkspaceLabelStorageUncertainError(); + } + + private async loadAndRecover(): Promise { + const transaction = await this.readTransaction(); + if (transaction) { + await this.recover(transaction); + this.loaded = true; + return; + } + this.labels = await this.readCatalog(); + this.loaded = true; + } + + private async readCatalog(): Promise { + try { + const raw = await fs.readFile(this.filePath, "utf8"); + return z.array(WorkspaceLabelDefinitionSchema).parse(JSON.parse(raw)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + } + + private async readTransaction(): Promise { + try { + return await this.requireTransaction(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + } + + private async requireTransaction(): Promise { + const raw = await fs.readFile(this.transactionPath, "utf8"); + return WorkspaceLabelTransactionSchema.parse(JSON.parse(raw)); + } + + private async recover(transaction: WorkspaceLabelTransaction): Promise { + if (transaction.phase === "committed") { + // The marker exists only after both data files are durable. It is cleanup state, never an + // instruction to replay stale after-images over newer workspace mutations. + this.labels = await this.readCatalog(); + await this.removeTransaction(this.transactionPath).catch(() => undefined); + return; + } + const labels = transaction.beforeLabels; + const workspaceStates = transaction.beforeWorkspaces; + await this.workspaces.commitWorkspaceLabelMutation({ + stage: (workspaces) => ({ + updates: workspaceStates.flatMap((state) => { + const current = workspaces.get(state.workspaceId); + return current ? [{ ...current, labels: state.labels, updatedAt: state.updatedAt }] : []; + }), + result: undefined, + forcePersist: true, + }), + beforeWorkspaceWrite: () => this.writeCatalog(this.filePath, labels), + afterWorkspaceWrite: () => this.removeTransaction(this.transactionPath), + afterCommit: () => { + this.labels = [...labels]; + }, + publish: false, + }); + } +} + +function transactionFor( + currentLabels: readonly WorkspaceLabelDefinition[], + mutation: WorkspaceLabelMutation, + workspaces: ReadonlyMap, +): WorkspaceLabelTransaction { + const beforeWorkspaces = mutation.workspaceUpdates.flatMap((workspace) => { + const current = workspaces.get(workspace.workspaceId); + return current ? [workspaceState(current)] : []; + }); + return { + phase: "prepared", + beforeLabels: currentLabels.map((label) => ({ ...label })), + afterLabels: mutation.labels.map((label) => ({ ...label })), + beforeWorkspaces, + afterWorkspaces: mutation.workspaceUpdates.map(workspaceState), + }; +} + +function workspaceState(workspace: PersistedWorkspaceRecord) { + return { + workspaceId: workspace.workspaceId, + ...(workspace.labels ? { labels: [...workspace.labels] } : {}), + updatedAt: workspace.updatedAt, + }; +} + +function catalogsEqual( + left: readonly WorkspaceLabelDefinition[], + right: readonly WorkspaceLabelDefinition[], +): boolean { + return ( + left.length === right.length && + left.every( + (label, index) => label.name === right[index]?.name && label.color === right[index]?.color, + ) + ); +} diff --git a/packages/server/src/server/workspace-labels/internal/sequence.ts b/packages/server/src/server/workspace-labels/internal/sequence.ts new file mode 100644 index 000000000..330180825 --- /dev/null +++ b/packages/server/src/server/workspace-labels/internal/sequence.ts @@ -0,0 +1,111 @@ +import { randomUUID } from "node:crypto"; +import type { WorkspaceLabelDefinition } from "@getpaseo/protocol/workspace-labels"; + +export type WorkspaceLabelChange = + | { kind: "upsert"; label: WorkspaceLabelDefinition; previousName?: string } + | { kind: "remove"; name: string }; + +interface SequencedChange { + generation: string; + seq: number; + change: WorkspaceLabelChange; +} + +export interface WorkspaceLabelCursor { + generation: string; + afterSeq: number; +} + +export interface WorkspaceLabelSync { + labels: WorkspaceLabelDefinition[]; + sync: { + mode: "snapshot" | "changes"; + generation: string; + headSeq: number; + removals: Array<{ name: string; seq: number }>; + }; +} + +export class WorkspaceLabelSequence { + private readonly generation = randomUUID(); + private headSeq = 0; + private readonly journal: SequencedChange[] = []; + private readonly subscribers = new Set<(change: SequencedChange) => void>(); + + constructor(private readonly journalLimit = 256) {} + + subscribe(listener: (change: SequencedChange) => void): () => void { + this.subscribers.add(listener); + return () => this.subscribers.delete(listener); + } + + publish(change: WorkspaceLabelChange): void { + const entry = { generation: this.generation, seq: ++this.headSeq, change }; + this.journal.push(entry); + if (this.journal.length > this.journalLimit) this.journal.shift(); + for (const subscriber of this.subscribers) { + try { + subscriber(entry); + } catch { + // Publication follows durable persistence and cannot fail the mutation. + } + } + } + + synchronize( + catalog: readonly WorkspaceLabelDefinition[], + cursor?: WorkspaceLabelCursor, + ): WorkspaceLabelSync { + const oldestSeq = this.journal[0]?.seq ?? this.headSeq + 1; + const canCatchUp = + cursor?.generation === this.generation && + cursor.afterSeq <= this.headSeq && + cursor.afterSeq >= oldestSeq - 1; + if (!canCatchUp) { + return { + labels: catalog.map((label) => ({ ...label })), + sync: { + mode: "snapshot", + generation: this.generation, + headSeq: this.headSeq, + removals: [], + }, + }; + } + + const changes = this.journal.filter((entry) => entry.seq > cursor.afterSeq); + const upserts = new Map(); + const removals = new Map(); + for (const entry of changes) { + if (entry.change.kind === "remove") { + const key = entry.change.name.toLowerCase(); + const wasCreatedDuringCatchUp = upserts.delete(key); + if (!wasCreatedDuringCatchUp) { + removals.set(key, { name: entry.change.name, seq: entry.seq }); + } + continue; + } + if (entry.change.previousName) { + const previousKey = entry.change.previousName.toLowerCase(); + const wasCreatedDuringCatchUp = upserts.delete(previousKey); + if (!wasCreatedDuringCatchUp) { + removals.set(previousKey, { + name: entry.change.previousName, + seq: entry.seq, + }); + } + } + removals.delete(entry.change.label.name.toLowerCase()); + upserts.set(entry.change.label.name.toLowerCase(), entry.change.label); + } + return { + labels: [...upserts.values()], + sync: { + mode: "changes", + generation: this.generation, + headSeq: this.headSeq, + removals: [...removals.values()], + }, + }; + } +} diff --git a/packages/server/src/server/workspace-labels/internal/service.ts b/packages/server/src/server/workspace-labels/internal/service.ts new file mode 100644 index 000000000..f56095983 --- /dev/null +++ b/packages/server/src/server/workspace-labels/internal/service.ts @@ -0,0 +1,298 @@ +import { + normalizeWorkspaceLabelName, + workspaceLabelKey, + type WorkspaceLabelDefinition, +} from "@getpaseo/protocol/workspace-labels"; +import { WorkspaceLabelCatalogStore } from "./catalog-store.js"; +import { + WorkspaceLabelSequence, + type WorkspaceLabelChange, + type WorkspaceLabelCursor, +} from "./sequence.js"; +import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; + +interface AssignmentCommit { + definition: WorkspaceLabelDefinition; + workspaceLabels: string[]; + catalogChanged: boolean; +} + +interface UpdateCommit { + label: WorkspaceLabelDefinition; + affectedWorkspaceCount: number; + changed: boolean; + previousName?: string; +} + +interface DeleteCommit { + affectedWorkspaceCount: number; + deletedName: string | null; +} + +export class WorkspaceLabelError extends Error { + constructor( + public readonly code: + | "label_name_empty" + | "label_not_found" + | "label_name_taken" + | "workspace_not_found", + message: string, + ) { + super(message); + this.name = "WorkspaceLabelError"; + } +} + +export class WorkspaceLabelService { + private operations: Promise = Promise.resolve(); + + constructor( + private readonly catalog: WorkspaceLabelCatalogStore, + private readonly sequence = new WorkspaceLabelSequence(), + ) {} + + async initialize(): Promise { + await this.catalog.initialize(); + } + + async subscribe(input: { + cursor?: WorkspaceLabelCursor; + onChange: (change: WorkspaceLabelChange & { generation: string; seq: number }) => void; + }): Promise<{ + snapshot: ReturnType; + unsubscribe: () => void; + }> { + return this.exclusive(async () => { + const unsubscribe = this.sequence.subscribe((entry) => + input.onChange({ ...entry.change, generation: entry.generation, seq: entry.seq }), + ); + try { + const labels = await this.catalog.list(); + return { snapshot: this.sequence.synchronize(labels, input.cursor), unsubscribe }; + } catch (error) { + unsubscribe(); + throw error; + } + }); + } + + async setAssignment(input: { + workspaceId: string; + label: WorkspaceLabelDefinition; + assigned: boolean; + }): Promise<{ label: WorkspaceLabelDefinition; workspaceLabels: string[] }> { + return this.exclusive(async () => { + const name = requireName(input.label.name); + const committed = await this.catalog.commit((catalog, workspaces) => { + const workspace = workspaces.get(input.workspaceId); + if (!workspace || workspace.archivedAt) { + throw new WorkspaceLabelError("workspace_not_found", "Workspace not found"); + } + const key = workspaceLabelKey(name); + const existing = catalog.find((label) => workspaceLabelKey(label.name) === key); + const definition = existing ?? { name, color: input.label.color }; + const current = workspace.labels ?? []; + const workspaceLabels = updateAssignmentLabels( + current, + key, + definition.name, + input.assigned, + ); + const workspaceChanged = workspaceLabels !== current; + const catalogChanged = !existing && input.assigned; + return { + labels: catalogChanged ? [...catalog, definition] : [...catalog], + workspaceUpdates: workspaceChanged + ? [ + { + ...workspace, + labels: workspaceLabels.length > 0 ? workspaceLabels : undefined, + updatedAt: new Date().toISOString(), + }, + ] + : [], + result: { definition, workspaceLabels, catalogChanged }, + }; + }); + if (committed.catalogChanged) { + this.sequence.publish({ kind: "upsert", label: committed.definition }); + } + return { label: committed.definition, workspaceLabels: committed.workspaceLabels }; + }); + } + + /** + * Edit a label: a new name, a new colour, or both, in one catalog commit. + * + * Both fields are optional and an omitted field is left alone. They travel together because + * the alternative — a rename that succeeds and a recolour that then fails — leaves the catalog + * in a state nobody asked for and no client can describe. + * + * Renaming onto a name the host already has is rejected, not merged. Merging would fold two + * labels' assignments together with no way back, and the user asked to edit one label. The + * check happens inside the planner, so a rejected collision applies neither field. + */ + async update(input: { + name: string; + newName?: string; + color?: WorkspaceLabelDefinition["color"]; + }): Promise<{ label: WorkspaceLabelDefinition; affectedWorkspaceCount: number }> { + return this.exclusive(async () => { + const fromKey = workspaceLabelKey(requireName(input.name)); + const newName = input.newName === undefined ? null : requireName(input.newName); + const committed = await this.catalog.commit((catalog, workspaces) => { + const existing = catalog.find((label) => workspaceLabelKey(label.name) === fromKey); + if (!existing) throw new WorkspaceLabelError("label_not_found", "Label not found"); + // A label keeping its own key is not colliding with itself, so case-only edits pass. + if (newName !== null && workspaceLabelKey(newName) !== fromKey) { + const toKey = workspaceLabelKey(newName); + if (catalog.some((label) => workspaceLabelKey(label.name) === toKey)) { + throw new WorkspaceLabelError( + "label_name_taken", + "A label with that name already exists", + ); + } + } + const nameChanged = newName !== null && newName !== existing.name; + const colorChanged = input.color !== undefined && input.color !== existing.color; + if (!nameChanged && !colorChanged) { + return { + labels: [...catalog], + workspaceUpdates: [], + result: { label: existing, affectedWorkspaceCount: 0, changed: false }, + }; + } + const label = { + name: nameChanged && newName !== null ? newName : existing.name, + color: input.color ?? existing.color, + }; + // Workspaces store names, so only a rename rewrites assignments; a recolour is catalog-only. + const workspaceUpdates = nameChanged + ? rewriteAssignments(workspaces, fromKey, label.name) + : []; + return { + labels: catalog.map((candidate) => (candidate === existing ? label : candidate)), + workspaceUpdates, + result: { + label, + affectedWorkspaceCount: workspaceUpdates.length, + changed: true, + ...(nameChanged ? { previousName: existing.name } : {}), + }, + }; + }); + if (committed.changed) { + this.sequence.publish({ + kind: "upsert", + label: committed.label, + previousName: committed.previousName, + }); + } + return { + label: committed.label, + affectedWorkspaceCount: committed.affectedWorkspaceCount, + }; + }); + } + + async delete(nameInput: string): Promise<{ affectedWorkspaceCount: number }> { + return this.exclusive(async () => { + const key = workspaceLabelKey(requireName(nameInput)); + const committed = await this.catalog.commit((catalog, workspaces) => { + const existing = catalog.find((label) => workspaceLabelKey(label.name) === key); + if (!existing) { + return { + labels: [...catalog], + workspaceUpdates: [], + result: { affectedWorkspaceCount: 0, deletedName: null }, + }; + } + const workspaceUpdates = rewriteAssignments(workspaces, key); + return { + labels: catalog.filter((label) => label !== existing), + workspaceUpdates, + result: { + affectedWorkspaceCount: workspaceUpdates.length, + deletedName: existing.name, + }, + }; + }); + if (committed.deletedName) { + this.sequence.publish({ kind: "remove", name: committed.deletedName }); + } + return { affectedWorkspaceCount: committed.affectedWorkspaceCount }; + }); + } + + async countAffectedWorkspaces(nameInput: string): Promise { + return this.exclusive(() => { + const key = workspaceLabelKey(requireName(nameInput)); + return this.catalog.commit((catalog, workspaces) => ({ + labels: [...catalog], + workspaceUpdates: [], + result: [...workspaces.values()].filter((workspace) => workspaceHasLabel(workspace, key)) + .length, + })); + }); + } + + private async exclusive(operation: () => Promise): Promise { + const previous = this.operations; + let release!: () => void; + this.operations = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await operation(); + } finally { + release(); + } + } +} + +function updateAssignmentLabels( + current: string[], + key: string, + displayName: string, + assigned: boolean, +): string[] { + const assignedIndex = current.findIndex((label) => workspaceLabelKey(label) === key); + if (assigned) return assignedIndex === -1 ? [...current, displayName] : current; + return assignedIndex === -1 ? current : current.filter((_, index) => index !== assignedIndex); +} + +function workspaceHasLabel(workspace: PersistedWorkspaceRecord, key: string): boolean { + return (workspace.labels ?? []).some((label) => workspaceLabelKey(label) === key); +} + +function rewriteAssignments( + workspaces: ReadonlyMap, + fromKey: string, + to?: string, +): PersistedWorkspaceRecord[] { + const now = new Date().toISOString(); + return [...workspaces.values()].flatMap((workspace) => { + const labels = workspace.labels ?? []; + if (!labels.some((label) => workspaceLabelKey(label) === fromKey)) return []; + const next = labels.flatMap((label) => { + if (workspaceLabelKey(label) !== fromKey) return [label]; + return to ? [to] : []; + }); + return [ + { + ...workspace, + labels: next.length > 0 ? next : undefined, + updatedAt: now, + }, + ]; + }); +} + +function requireName(raw: string): string { + const name = normalizeWorkspaceLabelName(raw); + if (name.length === 0) { + throw new WorkspaceLabelError("label_name_empty", "Label name cannot be empty"); + } + return name; +} diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 75d0097b0..80c328ce7 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { beforeEach, afterEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../test-utils/test-logger.js"; +import { writeJsonFileAtomic } from "./atomic-file.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -99,6 +100,57 @@ describe("workspace registries", () => { expect(await projectRegistry.list()).toEqual([]); }); + test("preserves a concurrent project update when archiving", async () => { + let pauseNextWrite = false; + let releaseWrite!: () => void; + let writeStarted!: () => void; + const started = new Promise((resolve) => { + writeStarted = resolve; + }); + const release = new Promise((resolve) => { + releaseWrite = resolve; + }); + const concurrentRegistry = new FileBackedProjectRegistry( + path.join(tmpDir, "projects", "concurrent-projects.json"), + logger, + { + writeRecords: async (filePath, records) => { + if (pauseNextWrite) { + pauseNextWrite = false; + writeStarted(); + await release; + } + await writeJsonFileAtomic(filePath, records); + }, + }, + ); + const project = createPersistedProjectRecord({ + projectId: "project-concurrent", + rootPath: "/tmp/project-concurrent", + kind: "git", + displayName: "project-concurrent", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + await concurrentRegistry.upsert(project); + pauseNextWrite = true; + + const update = concurrentRegistry.update(project.projectId, (current) => ({ + ...current, + customName: "Kept name", + updatedAt: "2026-03-02T00:00:00.000Z", + })); + await started; + const archive = concurrentRegistry.archive(project.projectId, "2026-03-03T00:00:00.000Z"); + releaseWrite(); + await Promise.all([update, archive]); + + expect(await concurrentRegistry.get(project.projectId)).toMatchObject({ + customName: "Kept name", + archivedAt: "2026-03-03T00:00:00.000Z", + }); + }); + test("publishes only project mutations that change the persisted lifecycle", async () => { await projectRegistry.initialize(); const mutations: Array<{ diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 5233e77e8..bbfef53a3 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -92,6 +92,7 @@ const PersistedWorkspaceRecordSchema = z.object({ .nullable() .optional() .transform((value) => value ?? null), + labels: z.array(z.string()).optional(), }); export type PersistedProjectRecord = z.infer; @@ -167,12 +168,14 @@ type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord; class FileBackedRegistry { private readonly filePath: string; - private readonly logger: Logger; + protected readonly logger: Logger; private readonly schema: z.ZodType; private readonly getId: (record: TRecord) => string; private loaded = false; private readonly cache = new Map(); - private persistQueue: Promise = Promise.resolve(); + private mutationQueue: Promise = Promise.resolve(); + private mutationsBlockedUntilRestart = false; + private readonly writeRecords: (filePath: string, records: readonly TRecord[]) => Promise; constructor(options: { filePath: string; @@ -180,6 +183,7 @@ class FileBackedRegistry { schema: z.ZodType; getId: (record: TRecord) => string; component: string; + writeRecords?: (filePath: string, records: readonly TRecord[]) => Promise; }) { this.filePath = options.filePath; this.schema = options.schema; @@ -188,6 +192,7 @@ class FileBackedRegistry { module: "workspace-registry", component: options.component, }); + this.writeRecords = options.writeRecords ?? writeJsonFileAtomic; } async initialize(): Promise { @@ -214,22 +219,21 @@ class FileBackedRegistry { } async upsert(record: TRecord): Promise { - await this.load(); const parsed = this.schema.parse(record); - this.cache.set(this.getId(parsed), parsed); - await this.enqueuePersist(); + await this.mutateCache((records) => { + records.set(this.getId(parsed), parsed); + return undefined; + }); } async update(id: string, updater: (record: TRecord) => TRecord): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) { - return null; - } - const next = this.schema.parse(updater(existing)); - this.cache.set(id, next); - await this.enqueuePersist(); - return next; + return this.mutateCache((records) => { + const existing = records.get(id); + if (!existing) return null; + const next = this.schema.parse(updater(existing)); + records.set(id, next); + return next; + }); } async archive(id: string, archivedAt: string): Promise { @@ -237,30 +241,23 @@ class FileBackedRegistry { } protected async archiveIfPresent(id: string, archivedAt: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) return null; - return this.persistArchive(existing, archivedAt); + return this.mutateCache((records) => { + const existing = records.get(id); + if (!existing) return null; + const next = this.schema.parse({ ...existing, updatedAt: archivedAt, archivedAt }); + records.set(id, next); + return next; + }); } protected async archiveIfActive(id: string, archivedAt: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing || existing.archivedAt) { - return null; - } - return this.persistArchive(existing, archivedAt); - } - - private async persistArchive(existing: TRecord, archivedAt: string): Promise { - const next = this.schema.parse({ - ...existing, - updatedAt: archivedAt, - archivedAt, + return this.mutateCache((records) => { + const existing = records.get(id); + if (!existing || existing.archivedAt) return null; + const next = this.schema.parse({ ...existing, updatedAt: archivedAt, archivedAt }); + records.set(id, next); + return next; }); - this.cache.set(this.getId(next), next); - await this.enqueuePersist(); - return next; } async remove(id: string): Promise { @@ -268,14 +265,12 @@ class FileBackedRegistry { } protected async removeIfPresent(id: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) { - return null; - } - this.cache.delete(id); - await this.enqueuePersist(); - return existing; + return this.mutateCache((records) => { + const existing = records.get(id); + if (!existing) return null; + records.delete(id); + return existing; + }); } private async load(): Promise { @@ -299,16 +294,68 @@ class FileBackedRegistry { this.loaded = true; } - private async persist(): Promise { - const records = Array.from(this.cache.values()); - await writeJsonFileAtomic(this.filePath, records); + protected async mutateMany( + updater: (records: ReadonlyMap) => readonly TRecord[], + ): Promise { + return this.mutateCache((records) => { + const changed = updater(records); + if (changed.length === 0) return []; + const parsed = changed.map((record) => this.schema.parse(record)); + for (const record of parsed) records.set(this.getId(record), record); + return parsed; + }); } - private async enqueuePersist(): Promise { - const nextPersist = this.persistQueue.then(() => this.persist()); - this.persistQueue = nextPersist.catch(() => {}); - await nextPersist; + protected async mutateCache( + updater: (records: Map) => TResult, + hooks?: { + forcePersist?: (result: TResult) => boolean; + beforeWrite?: (records: readonly TRecord[]) => Promise; + afterWrite?: () => Promise; + afterCommit?: () => void; + }, + ): Promise { + const previous = this.mutationQueue; + let release!: () => void; + this.mutationQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + await this.load(); + if (this.mutationsBlockedUntilRestart) { + throw new Error("Workspace registry mutations are blocked until daemon restart"); + } + const staged = new Map(this.cache); + const result = updater(staged); + const recordsChanged = !mapsEqual(this.cache, staged); + if (!recordsChanged && !hooks?.forcePersist?.(result)) return result; + const records = Array.from(staged.values()); + await hooks?.beforeWrite?.(records); + if (recordsChanged) await this.writeRecords(this.filePath, records); + await hooks?.afterWrite?.(); + if (recordsChanged) { + this.cache.clear(); + for (const [id, record] of staged) this.cache.set(id, record); + } + hooks?.afterCommit?.(); + return result; + } finally { + release(); + } } + + protected freezeMutationsUntilRestart(): void { + this.mutationsBlockedUntilRestart = true; + } +} + +function mapsEqual(left: Map, right: Map): boolean { + if (left.size !== right.size) return false; + for (const [key, value] of left) { + if (right.get(key) !== value) return false; + } + return true; } export class FileBackedProjectRegistry @@ -325,13 +372,24 @@ export class FileBackedProjectRegistry }) => void | Promise >(); - constructor(filePath: string, logger: Logger, options?: { projectIdFactory?: () => string }) { + constructor( + filePath: string, + logger: Logger, + options?: { + projectIdFactory?: () => string; + writeRecords?: ( + filePath: string, + records: readonly PersistedProjectRecord[], + ) => Promise; + }, + ) { super({ filePath, logger, schema: PersistedProjectRecordSchema, getId: (record) => record.projectId, component: "projects", + writeRecords: options?.writeRecords, }); this.projectIdFactory = options?.projectIdFactory ?? generateProjectId; } @@ -445,13 +503,23 @@ export class FileBackedWorkspaceRegistry (mutation: WorkspaceMutation) => void | Promise >(); - constructor(filePath: string, logger: Logger) { + constructor( + filePath: string, + logger: Logger, + options?: { + writeRecords?: ( + filePath: string, + records: readonly PersistedWorkspaceRecord[], + ) => Promise; + }, + ) { super({ filePath, logger, schema: PersistedWorkspaceRecordSchema, getId: (record) => record.workspaceId, component: "workspaces", + writeRecords: options?.writeRecords, }); } @@ -509,8 +577,57 @@ export class FileBackedWorkspaceRegistry await this.notifyMutation({ kind: "remove", workspaceId, workspace: null }); } + async commitWorkspaceLabelMutation(input: { + stage: (records: ReadonlyMap) => { + updates: readonly PersistedWorkspaceRecord[]; + result: TResult; + forcePersist: boolean; + }; + beforeWorkspaceWrite: (records: readonly PersistedWorkspaceRecord[]) => Promise; + afterWorkspaceWrite: () => Promise; + afterCommit: () => void; + publish?: boolean; + }): Promise { + let changed: PersistedWorkspaceRecord[] = []; + const committed = await this.mutateCache( + (records) => { + const staged = input.stage(records); + changed = staged.updates.map((record) => PersistedWorkspaceRecordSchema.parse(record)); + for (const record of changed) records.set(record.workspaceId, record); + return { result: staged.result, forcePersist: staged.forcePersist }; + }, + { + forcePersist: (output) => output.forcePersist, + beforeWrite: input.beforeWorkspaceWrite, + afterWrite: input.afterWorkspaceWrite, + afterCommit: input.afterCommit, + }, + ); + if (input.publish !== false) { + await Promise.all( + changed.map((workspace) => + this.notifyMutation({ kind: "upsert", workspaceId: workspace.workspaceId, workspace }), + ), + ); + } + return committed.result; + } + + blockAllMutationsUntilRestart(): void { + this.freezeMutationsUntilRestart(); + } + private async notifyMutation(mutation: WorkspaceMutation): Promise { - await Promise.all([...this.mutationListeners].map((listener) => listener(mutation))); + await Promise.all( + [...this.mutationListeners].map(async (listener) => { + try { + await listener(mutation); + } catch (error) { + // Publication happens after the registry commit and cannot make durable state fail. + this.logger.error({ err: error, mutation }, "Workspace mutation listener failed"); + } + }), + ); } } @@ -556,6 +673,7 @@ export function createPersistedWorkspaceRecord(input: { archivedAt?: string | null; autoArchivedChangeRequestUrl?: string | null; pinnedAt?: string | null; + labels?: string[]; }): PersistedWorkspaceRecord { return PersistedWorkspaceRecordSchema.parse({ ...input,