Draft: Build agent-ready design proposal system (#16875)

## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - People need consistent UI patterns to review and control that work.
> - The current board uses related layouts and controls with different
spacing, type, and behavior.
> - That drift makes it hard for a designer or an agent to build a new
page from a reliable reference.
> - Storybook can show the shared patterns and the states they need to
support.
> - This pull request adds design proposals, a local sandbox, and
reusable UI controls so people can compare the patterns in context.
> - The benefit is a clearer design system that agents can use to build
consistent UI.

## Linked Issues or Issue Description

**Subsystem affected**

`ui/` — React board UI and Storybook.

**Problem or motivation**

Board pages repeat visual and interaction decisions. Storybook did not
provide a complete reference for task views, conversation states,
typography, toolbars, tabs, and light/dark colors. People could not
compare a proposed pattern with the running board in one place.

**Proposed solution**

Add a staged Design Proposals catalog with foundations, components,
layouts, and page specimens. Add an opt-in sandbox. Reuse production
components where possible. Include synthetic conversation states and
shared controls so reviewers can compare and refine them.

**Alternatives considered**

Separate static mockups would not show keyboard behavior, responsive
layout, or real component states. Rebuilding each page only in Storybook
would allow the reference to drift from the board.

**Roadmap alignment**

This improves existing board UI and Skills Manager surfaces. It does not
add a new control-plane capability.

## What Changed

- Added dark and light color references, typography and conversation
specimens, and component stories for buttons, tabs, toolbars, sidebar,
popovers, activity, and task lists.
- Added a local design sandbox with synthetic task conversation playback
and representative states.
- Shared collection toolbars and navigation patterns across task, agent,
project, routine, skill, connector, and artifact pages.
- Added the streamlined Skills tabs and a My Skills collection that uses
the shared skill cards.
- Restored saved-thread access during synthetic playback, kept connector
searchbox semantics, and updated reproduction checks after moving trial
stories.
- Documented the proposed patterns and added focused UI, catalog, and
browser checks.

## Verification

- `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node
scripts/storybook-catalog.mjs --check` passed.
- Full workspace `pnpm -r typecheck` and `pnpm build` passed after
merging master. After the review fixes, focused UI typecheck, `pnpm
build-storybook`, 23 Skills Sources tests, and all 15 Storybook
typography browser tests passed.
- Focused task-thread and system-notice suites passed: 251 tests.
Connector Browse passed 84 tests; the connector, attachment, recovery,
and Storybook flyout browser journeys passed locally.
- A local full Vitest run hit one intermittent chat-channels integration
failure; that test passed in isolation. GitHub CI
[39347996059](https://github.com/paperclipai/paperclip/actions/runs/39347996059)
passed on the exact latest commit: 55 successful checks and two skipped
draft-only Storybook jobs.
- Greptile reviewed the exact latest commit at 5/5 with no new comments
or open review threads. GitHub reports the branch as cleanly mergeable.

## Risks

- This PR changes 206 files and exceeds the repository's 100-file review
target. Review by commit and consider a scoped split before merge.
- Design Proposals are staged references. Fixture interactions do not
prove API behavior, permissions, or provider execution.
- UI changes to shared controls may affect pages outside the proposal
sandbox. Focused browser checks and the full CI matrix passed.

## Model Used

- OpenAI Codex, GPT-6 family. The exact runtime model ID and context
window are not exposed in this session. The model used reasoning, code
execution, browser inspection, and local tests. Earlier branch commits
were also AI-assisted in this Codex conversation.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details available in this session)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above where applicable
- [x] I have either linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Scott Tong <scott@scotts-mbp-m5-max.local>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-10-19 17:45:26 -07:00
1 parent f4d21e2b84
commit 7b9334cd10
206 files changed
+13380 -925

No files matched your search

+774
View File
@@ -117,6 +117,675 @@ reminder; dismissing one clears the composer and stays effective after reload fo
that person and task. Other inputs keep a pending indicator that can reopen them;
resolving or skipping the input removes that indicator.
## Proposed color foundation
Review **Design proposals → Foundations → Colors** in Storybook
(`design-foundations-colors`): Token reference, Surfaces & elevation, and
Text & interaction. The palette comes from the [Paper Dark mode frame](https://app.paper.design/file/01KZ5QQTS4TG2VADP7ZVGJNQY4/p-L-0/3LXU-2)
and the designer's usage rules. Dark mode retains these proposed values. Light mode inherits the existing
production semantic palette from `:root`, including foreground, background,
border, input, accent, status and ring colors. Proposal-only `--muted-alt`
aliases production `--secondary`; the proposed light sidebar uses `--primary-sidebar-background`, the same
border/background blend rendered by the production primary sidebar. Light
navigation uses production foreground at 80% opacity and sidebar-accent for
selected/hovered items, rather than the dark proposal’s muted mapping.
Storybook proposals follow the theme toolbar instead of locking dark mode. It remains
proposed until visual approval.
Paper's `dark-color-*` names map to theme-independent CSS tokens in
`ui/src/index.css`; do not introduce a second token file or copy colors into
components/stories. The token-reference story reads values from the active CSS.
| Paper name | Code token | Intended use |
| --- | --- | --- |
| `dark-color-foreground` | `--foreground` | Headings, header labels, emphasized text, unread task titles |
| `dark-color-background` | `--background` | Center/right panes, lowest card surface, menus and popovers |
| `dark-color-background-alt` | `--background-alt` | Text inputs and search fields |
| `dark-color-muted` | `--muted` | Left sidebar and highest card surface |
| `dark-color-muted-highlight` | `--muted-highlight` | Hover backgrounds; preserve documented selection/sort behavior |
| `dark-color-muted-alt` | `--muted-alt` | Middle card surface |
| `dark-color-muted-foreground` | `--muted-foreground` | Default text, secondary labels, metadata, read task titles |
| `dark-color-border` | `--border` | Shared dividers, input outlines and card borders |
Focus indicators use `--ring: var(--foreground)`, matching `dark-color-foreground`.
A 1px bright edge and a 3px outer halo at 20% opacity give focus two levels of
emphasis, using `--focus-ring-stroke-width`, `--focus-ring-halo-width`, and
`--focus-ring-halo`. Inputs, buttons and the proposed custom focus indicators
share this treatment. This additional reference also supplies sidebar focus. Resize controls
retain their neutral gray via `--pane-resize-highlight`, independent of focus
elsewhere. The focus colors are listed alongside the Paper palette in the token
story as derivatives of foreground. The override is scoped to the proposed dark theme.
The border is translucent: its rendered shade depends on the surface beneath it.
Keep that alpha value rather than replacing it with a different opaque gray for
each background. Card surface levels share this border and do not automatically
add shadows. Use the existing `Card` with `elevation="low"`, `"middle"`, or `"high"`;
omitting elevation keeps its default surface. These new surface mappings apply
in both proposal themes, using their respective tokens.
Most text is muted foreground. Foreground is an intentional exception for
headings, header labels and emphasis. Task-title read/unread weight and hover
rules below remain in force. Task-status colors keep their existing semantic
meaning and are outside this neutral-palette reference.
The Storybook proposal decorator adds `.design-proposal-theme`. Dark semantic
overrides are scoped to its presence on the document so portaled menus inherit
the same colors. Leaving a proposal removes that scope. Existing production
stories and the app's default theme retain their existing appearance; the
light palette is reused without redefining its production colors. Activity shimmer
and interactive metadata emphasis follow `--foreground`; human bubble text stays white on blue. The sidebar, page shell,
task list, shared inputs, card levels and refreshed flyouts consume these same
semantic tokens.
## Proposed typography foundation
Review **Design proposals → Foundations → Typography → Type specification**
(`design-foundations-typography--type-specification`). The proposal and the
opt-in design sandbox use Inter Variable for interface text and bundled Paper
Mono for all monospace text, including IDs, code and run diagnostics.
**Conversation styles** (`design-foundations-typography--conversation-styles`)
shows the same composition as the sandbox task detail page by mounting
`DesignSandboxTaskThread` directly. Its canonical 72-state fixture catalog,
grouped selector, replay, history, activity disclosures, transcript/native paths
and local composer actions are shared, rather than independently assembled
examples. It defaults to All states. Optional computed typography measurements
inspect rendered text, including expanded disclosures; refresh after changing
states or opening content. Storybook does not load saved backend conversations.
The fixtures use production renderers with synthetic data; this does not certify
a deployed production version. Refine shared tokens/renderers after review so
subsequent typography changes propagate back to the sandbox.
**Conversation type spec** (`design-foundations-typography--conversation-type-specification`)
uses the Type specification layout to dissect the shared conversation preview.
Its size inventory, family/weight specimens, searchable usage rows and shared-
metric consolidation candidates derive from browser measurements of the same
72-state composition. Source classes and example-state links connect each row
to its owning surface. Measurements are snapshots: refresh after opening
content or selecting another state. Closed disclosures, placeholders and absent
Markdown structures are excluded. Isolated text specimens copy measured styles;
the shared composition remains available in context. Consolidation requires a
subsequent change to shared tokens/renderers, which then propagates to the sandbox.
The sandbox demonstration tasks now open a **Play conversation** preview rather
than stacking all state fixtures. A local, token-timed controller progressively
feeds one cumulative synthetic transcript through the real conversation
renderers: an initial question, reasoning, commentary, file read, command and
streaming final, then a follow-up with queuing, planning, clarification and
permission receipts, a failed test and retry, file preview, usage and a second
final. Demo answers and permissions advance automatically; no approval affects
a real run. Settled turns retain inspectable Activity history. Pause freezes the preview clock; resume continues
and replay resets it. At completion the response keeps the same mounted bubble:
preceding live activity fades over 180ms, then releases its height over 480ms so
the final reply glides into place. The Activity footer appears after the fold;
reduced motion settles immediately. This handoff is shared by the Storybook and
sandbox playback. Preview prose arrives in complete words with a subtle
320ms fade from near-transparent on newly mounted word spans; existing words remain stable. Reduced
motion disables the word fade. No provider or command runs. Saved conversations on other
tasks remain available. The Typography **Conversation playback** story uses the
same controller; Conversation styles and Conversation type spec retain the
72-state catalog for detailed inspection.
Conversation typography is now consolidated inside explicit shared thread and
composer regions under the proposal theme: body text is 14px/20px and metadata
is 12px/16px. Human messages retain blue bubbles with white text. Final agent
responses use foreground; commentary, reasoning, activity labels and metadata
use muted foreground without extra opacity tiers. Interactive metadata
returns to white text with a subtle neutral background on hover or keyboard
focus; noninteractive metadata stays muted. Agent names use 14px/20px semibold
in foreground.
Only the current activity owns white shimmer: Working while awaiting activity,
then the active reasoning or tool line. Working returns to muted foreground
while another activity owns shimmer; streaming final text does not shimmer.
The sweep loops continuously without an end pause. Tool labels and Paper Mono
targets share one gradient across their containing line, rather than animating
each text segment independently; reduced motion restores solid readable text.
Completed tools return to muted metadata. Runner preparation and transcript
loading placeholders are omitted from the normal live tail; actionable queues,
recovery and failure messages remain. Explicit final-channel text is foreground while
streaming; unclassified/progress deltas stay muted. Reduced motion disables
tool shimmer and word fades. Markdown headings stay 14px
normal and retain their semantic elements and spacing; final-response headings
use foreground. Explicit strong emphasis remains semibold. Authored code follows
14px body text; diagnostic output follows 12px metadata in Paper Mono. Links
inherit color and remain underlined. Semantic status accents, action-button
variants and keyboard-focus treatments remain meaningful exceptions. Standalone
Markdown outside conversation regions retains its existing scale. Removing the
proposal theme restores ordinary conversation styling.
Only normal (400) and semibold (600) are part of this proposal. Medium recipes
resolve to normal; bold recipes resolve to semibold. The size ramp is nano
(10px), xs (12px), compact (13px), sm (14px), base (16px), lg (18px), xl (20px),
2xl (24px) and 3xl (30px). Former micro usages use xs, including message times,
account email and technical metrics. Navigation group labels and streamlined
property section labels share the same sans/nano/normal/uppercase recipe,
150% leading, 0.1em tracking and muted foreground at 60%.
These decisions live in `ui/src/index.css`, under the shared proposal marker.
Compatibility aliases carry existing shared components onto the proposed
families, weights and sizes, including portals. New proposal code uses normal,
semibold and xs directly. The ordinary app theme retains its original
typography. The Buttons playground uses the sandbox proposal theme. Do not
remove those defaults while this design is being tested.
## Task-row refresh: proposed specimen
The task-row refresh is an explicitly authorized design change following the
extraction run. Its review surface is **Design proposals → Components → Task list** in Storybook
(`tasks-row-design`), using the production `IssueRow` with
`presentation="task" appearance="refreshed"` and the shared `TaskRowGroup`.
The existing row presentation remains the default while this specimen is reviewed.
Stage this design-system refresh's new token, component, layout, and screen stories
under **Design proposals**, in `ui/storybook/stories/design-proposals/`. Organize
sidebar titles by Foundations (tokens), Components (reusable UI), Layouts
(reusable page structures), Pages (complete screens), Patterns (shared behavior),
and Guidelines (usage and contribution rules). Add groups as stories exist;
do not create empty placeholder stories. Use
`home: "design-proposals"`, `status: "proposed"`, and an explicit staging reason.
These stories are review references and do not count as current production coverage.
All Design proposals opt into `AvatarInitialProvider`: text fallback avatars use
one uppercase character, the first initial. This includes the sidebar account
control, account/profile headers, identity avatars and failed agent-image
fallbacks. Uploaded images and organization artwork keep their existing rendering.
They continue to import shared components and the existing token layer; this is
organizational separation, not a second implementation or token source. Use them
for tasks explicitly implementing the refresh. After design review and adoption
in the app, move each suite to its feature or Foundations home and update its
classification while retaining its explicit story ID and exports.
- Comfortable density is 44px; compact density is 32px. Comfortable is the default.
Compact is a preview for a future user preference, not a new app setting yet.
- All text in proposed task rows and table headers uses the `sm` size token
(`--text-sm`, 14px) with a 16px line height. This includes metadata, IDs and tag
chips; keep the same type size at both row densities.
- Titles use Inter: read titles are regular (400), muted foreground; unread titles
are semibold (600), foreground. Hover changes the row background and title to
foreground while preserving its read/unread weight. Proposed task-row hover
backgrounds use `--muted`; other hover highlights retain their existing tokens.
Keyboard focus reveals the
same title emphasis and retains a visible focus indicator.
- Refreshed task rows indicate unread state through title typography alone. Do not
show the old blue unread dot or reserve space for it, including in inbox rows.
- Task identifiers use bundled Paper Mono. All visual values live in the
`--task-row-*` token family in `ui/src/index.css`, mapped to the shared semantic
color tokens (`--foreground`, `--muted-foreground`, `--border`).
- Keep an 8px gap between the status icon and title in both row specimens and
the complete column layout. Status icons show their text label in the shared
tooltip on hover or keyboard focus; clicking still opens the task. Use the
shared status label, including waiting/blocked context, rather than duplicating
status copy. Title text continues to use its marquee without a tooltip.
- Titles stay on one line with ellipsis. Every truncated column uses the same
`CellMarquee`: hover reveals only the cell in that column, scrolling left once
at a constant speed until the last character is visible, then holding there.
Moving to another column or leaving the row resets immediately. Fitting cells
stay still. Keyboard focus also reveals truncated cells; Escape stops the
reveal. Recheck overflow when width, text, or fonts change. Reduced motion
jumps to the end without animation. There is no title tooltip; full text
remains accessible, and the full title stays in the row link's accessible name.
Truncated edges use a subtle 12px alpha fade into the actual row background,
including hover, selection and other highlights. Fitting cells have no fade.
The marquee also fades its clipped leading edge; once it reaches the end, the
trailing fade clears so the final character remains fully visible. Keep the
mask inside the focusable cell so keyboard focus outlines stay intact.
- Each nesting level adds 24px inside the title area. Trailing metadata columns
stay aligned. Thin vertical guides communicate ancestry, with room for disclosure
controls. Disclosure controls retain independent keyboard and pointer behavior.
- A root task and its visible descendants form one `TaskRowGroup`, bounded by a
separator above the parent and below its final visible descendant. Child rows
have no horizontal dividers. Adjacent groups share a single separator. The outer
nesting guide stops above the bottom separator with the same gap as between
its top and the parent's status icon.
Review read/unread, actual hover/focus, long titles, both densities, and an expanded
two-level hierarchy before composing the full task-list screen. Story fixtures
show local UI behavior; they do not prove live persistence or task authorization.
### Task-list columns
The complete column order is **Task, Task ID, Created by, Assignee,
Project, Parent Task, Tags, Worktree, Last updated**. The reusable
`TaskListColumnHeader` and `IssueRow`'s `columnValues` share the `--task-list-*`
width tokens and configurable column order. Review the complete composition in
**Design proposals → Components → Task list → All columns**.
- Created by is the recorded creator (human or agent); Assignee is the current
assignee. Both show names without avatars. Do not infer either from tree depth
or task status.
- Task combines the status icon and title under one header and one Columns-menu
choice. Sorting Task uses the title. Status sorting is unavailable for now.
- Parent Task refers to the structural parent; it can show its ID and title.
- Last updated comes from `updatedAt`. Worktree shows the linked Git worktree's
branch name. Unassigned tasks say “Unassigned”; other missing values use “—”.
- Default visibility is Task, Task ID, and Last updated. The All columns specimen
starts with all nine visible for review. Widths are provisional: preserve
a readable title area and scroll horizontally on narrower screens rather than
dropping requested fields. Nesting consumes title space; metadata stays aligned.
- Fade clipped left/right edges into the background only while more content is
hidden on that side. Remove both fades when the table fits. Keep scrolling and
row controls usable through the fade.
- Tag chips use muted-foreground text on a muted-highlight background
(`--muted-foreground` and `--muted-highlight`). In dark mode, muted-highlight
matches Paper's `--dark-color-muted-highlight` (`#333333`).
- The column header stays 44px high in both row densities. Every visible header
is a keyboard-accessible sort button: a new column starts ascending and repeated
clicks toggle ascending/descending. The active label and direction arrow use
foreground on a persistent muted-highlight background; inactive labels use
muted foreground. Expose `aria-sort`.
All header backgrounds have 8px of padding on each side. Vertical padding stays 4px.
Preserve label alignment and column positions across hover states.
The Task header spans status and title and aligns its label with root-row
titles. Headers stay left-aligned, with sort arrows to the right. Sorting must
not move other columns or widen the gap between row status icons and task
titles. Every column uses the same 12px arrow.
- Drag a header left or right to reorder the entire column, including Task's
status/title pair and its nested rows. A click still sorts; dragging never
changes the sort. Alt+Left/Right reorders a focused header. Headers and all
rows share one grid; widths, nesting and metadata alignment survive reordering.
Last updated is last in the default table order and flyout order.
- Sorting keeps each parent before its descendants and sorts siblings at every
level. Task IDs and names use natural, case-insensitive ordering; timestamps use
their raw value. Tags sort by their
alphabetized names; missing values stay last in both directions. Equal values
retain their order. The initial collection uses Task ID ascending as its
default order, with no active header, direction arrow or persistent highlight.
A header becomes active only after the user sorts by it; the first click sorts
ascending.
- A horizontal ellipsis stays pinned to the right edge of the header, outside
horizontal scrolling. Its “Columns” flyout lists all nine columns in their current order
with checkmarks on the right. Toggling columns updates both header and rows
immediately and keeps the flyout open. Keep at least one column visible; nesting
controls remain available even when Task is hidden. Reset defaults shows only
Task, Task ID, and Last updated and restores the default column order.
Reset also clears the active sort, direction arrow and persistent header highlight.
Other columns remain available in the menu; hiding and re-enabling a column
preserves its position until reset.
Hiding the sorted column clears the explicit sort and restores the default
collection order without highlighting another header.
- The refresh's compact flyout uses the shared DropdownMenu primitives. The
Paper reference defines a 200px surface with an 8px radius, 12px content
padding, 32px items separated by 4px, regular-weight muted labels and 14px
checks on the right. Flyout text uses `sm` (14px) with a 20px line height;
the heading and Reset defaults use semibold foreground. Hover/focus promotes
labels and checks to foreground on muted-highlight. The dark surface matches
Paper's `--dark-color-background` (#1a1a1a), shared by the task list, nesting
groups, header controls, edge fades and the entire proposal canvas (including
its document/root background and surrounding content). The flyout has an
explicit 1px semantic border; the reset outline also uses the semantic tokens.
Table text retains its 16px line height.
The existing inbox picker remains tied to its older column contract and descriptions.
Sort, order and visibility changes in the specimen are local state; saving preferences
across navigation/reloads belongs to app adoption.
### App-shell layout proposal
Review **Design proposals → Layouts → App shell** (`design-app-shell`):
Two Panes, Three Panes, and Independent Scrolling. These use the existing
`DesignPageShell` and `DesignSidebar`, also composed by Pages → Tasks.
Pages supply the `sidebar`, `title`, and main `children`; optional `rightSidebar`
and `rightSidebarTitle` add a contextual right pane. Do not recreate this shell
inside a page or story. The shell owns pane geometry, fixed headers, and separate
main/right scrolling; each page owns its content and actions.
For tabbed contextual panes, supply `rightSidebarHeader` and
`rightSidebarControls`; `rightSidebarMaximized` expands the right pane across
the workspace while retaining the left navigation. `headerActions` can supply
a reopen control. The Three Panes and Independent Scrolling stories reuse the
task-detail page's production `SidePanelTabs`, `SidePanelLauncher`,
`SidePanelWindowControls`, and `useSidePanelTabs`. Their Properties, Tasks, Plan,
and Artifacts tabs can be selected, added, closed, and reordered; closing the
last tab reveals the launcher. Tab contents remain neutral fixtures. Closing
the pane preserves its tabs for reopening. Both content viewports use
`useScrollbarWhileScrolling` and `scrollbar-while-scrolling`: scrollbars appear
while moving and hide after the shared `--motion-scrollbar-idle-delay`.
The left sidebar retains its shared resizing behavior. The right pane defaults
to 320px, capped at 30% of the shell width on narrower desktops. Its divider
uses the left sidebar's neutral resize highlight on hover, keyboard focus, and dragging.
Dragging left widens the pane; arrow keys resize in 16px steps, and Home/End
select the bounds. Double-click clears its saved width and restores the default.
Both sidebars animate their double-click reset with the shared 200ms
`--motion-pane-width-reset` and `--motion-ease-pane-width-reset` tokens, matching
the task-detail right pane. Dragging and keyboard resizing remain immediate;
left navigation open, close, and peek behavior stays immediate. Reduced motion
sets the reset duration to zero. The app-shell stories expose this through both
dividers, using the same SidebarShell as the sandbox's left navigation.
Resizing keeps at least 260px for the right pane and, when space permits, 320px
for the main pane. Width preferences use `rightSidebarStorageKey`, defaulting
to `${sidebarStorageKey}.right`, independently of the left width. Maximizing
hides the resize handle and restoring retains the chosen width. All dimensions
use `--design-page-aside-*` and `--design-page-main-min-width` tokens.
Main and right panes use `--background`, the left
sidebar uses `--muted`, and dividers use `--border`. Layout stories use separate
sidebar-width storage keys and neutral placeholder content so review focuses on
the structure. These are proposed dark desktop layouts; mobile navigation and
right-panel overlay behavior require separate design work.
### Task-list page proposal
Review the complete composition in **Design proposals → Pages → Tasks**
(`tasks-list-page-design--default`), alongside All Columns and No Results.
This page remains proposed until the design owner approves the surrounding
layout. The Default story shows Task, Task ID and Last updated; the row specimen
and full page consume the same `TaskList` collection.
- `DesignPageShell` composes the existing 240px `SidebarShell`, a 60px page
header, and scrolling main content with 24px insets. The sidebar uses muted;
the entire main pane uses Paper's background token. Desktop is the current
review scope; mobile composition remains to be designed.
Enable the shared shell's production resize handle, including its hover,
dragging and keyboard states. Sidebar width persists under the proposal's own
storage key, separate from the production sidebar preference. Double-clicking
the resize handle restores and saves the default 240px width.
- Toolbar outlines use the opaque `--design-page-control-border` token so
search, view, layout, expansion and filter controls have the same visible
border despite their different fills. General surface borders continue to
use the translucent `--border` token. Disabled controls retain their normal
dimming, and keyboard focus retains its separate ring.
- The left sidebar clips its muted fill to the padding box so its translucent
right divider blends over the app background, matching the main header and
pane boundaries. The divider still uses `--border`; the resize handle keeps
its distinct hover/focus highlight.
- `DesignSidebar` is the shared proposed navigation component used by this page
and **Design proposals → Components → Sidebar**. Refine its Default, Narrow,
Long Labels, Many Recent Tasks, Empty Recent Tasks, Organization Menu and
Account Menu stories, then review the Tasks page in context. Both compositions
use the same component and CSS tokens. The caller supplies recent tasks and
the New Task action; recents are filtered to the selected company.
The Recent Tasks heading stays fixed while its list scrolls independently.
The list uses the existing `scrollbar-while-scrolling` utility and shared idle
timer token; hovering alone does not reveal its scrollbar.
Standalone examples use separate width preferences and share the page's owner-access
fixture boundary, existing dialogs, marquee and Popover appearance. Navigation
remains fixture based; mobile composition and backend behavior are not proved.
- Sidebar navigation labels use `sm` (14px). Icons match Paper and the existing
sidebar mappings: CircleCheck, MessageCircle, FolderOpen, Repeat, Package,
Users, Boxes, Unplug and History. The organization and account controls mount
`SidebarCompanyMenu` and `SidebarAccountMenu` directly, preserving their
production keyboard behavior and actions, with the shared refreshed flyout
appearance. Switching fixture
organizations changes the selection and never shows another company's tasks.
The sidebar follows Paper node `32TP-0`: 12px navigation insets, 8px item
margins and padding, 14px/150% labels, 2px row gaps and 16px section gaps.
Work and Org captions use the sans family rather than monospace. Recent Tasks
is a separate final section with five regular-weight, unindented links; it is
not nested beneath Tasks. The account footer has no divider above it.
Sidebar surface and right border alias `--muted` and `--border`, matching
Paper's dark-color-muted and dark-color-border. The task search field uses
`--background-alt` (Paper dark-color-background-alt: oklch(14.5% 0 0)).
The organization switcher has no caret in this proposal. Its hit area is
separate from an adjacent global-search button, which opens the production
`CommandPalette`; the toolbar input remains a task-list filter.
`TruncatedTextMarqueeProvider` opts sidebar and menu labels into the same
`CellMarquee` animation and alpha fade as table cells. Links and buttons own
focus; labels add no tab stops. Organization-menu state changes reset the
trigger title; restored focus after dismissal does not restart it outside the
hit area. Fresh pointer interaction or tabbing away and back can reveal it.
Pointer exit resets immediately, Escape stops
reveal, fitting labels stay still, and reduced motion follows the shared rules.
- `TaskListToolbar` composes shared buttons, input, tooltip, dropdown and filter
primitives. Controls are 44px high. A 32px gap separates the toolbar and table.
Page dimensions, spacing, type and surfaces live in the token layer under
`--design-page-*` and `--design-sidebar-*`.
The filter popover uses Paper's five peer groups in order: Status, Project,
Creator, Assignee, Label. All but Status have an always-visible search field.
Desktop groups are 200px wide with 16px gaps and 12px body insets; groups wrap
on smaller viewports. Checkbox rows use the existing filter state and toggle
helpers, with Clear resetting the active filters. This opt-in `refreshed`
presentation omits the old quick-filter and visibility sections and inherits
the shared Popover surface, highlight and `--shadow-flyout` token.
- Refreshed dropdowns and popovers share `FlyoutAppearanceProvider` and the
existing Radix primitives. `Flyout` / `FlyoutContent` compose a popover;
`FlyoutHeader` supports a simple title or profile details, and `FlyoutOptions`
groups the actions below it. Dropdowns reuse these sections and the appearance
provider while retaining menu keyboard behavior. Surfaces
use background, borders use border, hover/focus uses muted-highlight and
foreground. Unhighlighted labels and their checkmarks use
`--flyout-item-foreground`, bound to muted-foreground. Selection uses a checkmark,
including single-choice Views.
The shared `--shadow-flyout` token controls the subtle drop shadow. All flyout
visual values live under `--flyout-*`; menu-specific widths stay contextual.
The account flyout fits its contents and retains production Invite access
checks. The preview supplies owner access and loaded operator-policy fixtures.
This appearance remains opt-in while the proposal is reviewed.
Review the component separately in **Design proposals → Components → Popover**, with Columns,
Views and Account Menu examples. Account Menu mounts the canonical
`SidebarAccountMenu` with the same owner-access fixture boundary as the Sidebar
story; no separate action list or simulated account handlers are maintained.
The former Profile story keeps its stable URL and is named Account Menu.
Its Shadow control previews a local override;
changing the saved `--shadow-flyout` token in `ui/src/index.css` updates every
refreshed flyout. Storybook controls do not persist token edits.
- Views appear in this order: Inbox, Unread, Needs attention, All tasks. Inbox includes
tasks created by or assigned to the current user. Unread uses the task unread
state; Needs attention includes both blocked and in-review statuses. New users start
on All tasks. The last view persists in browser storage per user, across the
proposal stories and organizations, under an isolated design-proposal key.
Invalid or unavailable storage falls back to All tasks; changing views still
works if storage is denied. Cross-device preference sync requires app integration.
Search, views and filters operate on fixtures. Matching
subtasks keep their ancestor chain for context; the result count distinguishes
matches from contextual parents. Expand/collapse applies to every nested level.
The grouping icon and menu are omitted from the proposed toolbar.
- List/board switching preserves the list's local column state. The board and
New Task composer reuse existing components and are not new approved designs.
The proposed board uses one horizontal scroll viewport. It shares the task
table's edge detection and fade tokens, showing each fade only while content
is clipped on that side; resize and scrolling update the indicators.
Sidebar links exercise the memory router; destination pages, creation API
behavior, production preferences and production routes are outside this preview.
### Reuse collection behavior as well as appearance
`useTaskListCollection` in `ui/src/hooks/` is the shared presentation controller
for the proposed task page and reproduction trial. Supply accessible `items`,
the selected `companyId`, current `userId`, an isolated `viewStorageKey`, and
optionally `initialQuery`. Feed its query/view/layout/filter handlers to
`TaskListToolbar`, its `creators` directly to the Creator filter, and its
`visibleItems`, `companyItems` and expansion state to `TaskList`. Boards consume
`matchingItems`: contextual parents belong in the hierarchical list only.
The caller owns data loading and mutations; this hook does not authorize data
access, define server permissions, or integrate production persistence.
Creator IDs are namespaced (`user:<id>` / `agent:<id>`), including when human
and agent IDs coincide. Search includes task IDs, titles, creator, assignee,
project and tags. Changing query, view or filters expands matching families so
results cannot remain hidden behind collapsed ancestors. Clear resets search,
filters and view, while keeping the chosen layout. View storage remains isolated
by the caller's scope/user key. Company changes scope tasks and creator options
and start with expanded families.
The reproduction story now uses this controller too. It is a repaired regression
reference, not an untouched fresh-agent result. Preserve the first submission's
source, frozen PNGs and capture metadata separately when evaluating improvements;
do not replace its evidence with repaired output or silently accept new images.
The opt-in `task-list-reproduction-trial.config.ts` suite requires
`TRIAL_REFERENCE_PNG` pointing to the original frozen reference (and a matching
Chromium executable via `TRIAL_CHROMIUM_EXECUTABLE`). Missing visual evidence is
an error rather than an omitted comparison. Its browser cases exercise creator
filters, collapsed search/view/filter transitions, metadata search and board
matching on both the reference and trial. These checks establish fixture behavior
and reproduction of that frozen desktop state, not production acceptance.
The next milestone is the agent reference contract and another fresh-agent test
using the shared components, tokens and collection controller. A corrected trial
does not establish that a new agent will discover and reuse this system without
coaching. Visual approval and production integration remain separate milestones.
### Activity lists
**Design proposals → Components → Activity list** demonstrates the shared
`ActivityList` and the refreshed appearance of `ActivityRow`, including mixed
task, agent, project, approval, run and unlinked system events. Its Playground
exposes comfortable/compact density and standard/narrow/smallest container width.
It reuses task-row height, spacing, typography, ID/time widths and responsive
metadata rules, plus the same `CellMarquee` and `useCellMarqueeHover` behavior.
Activity is its own composition; events are not converted into synthetic tasks.
The caller supplies events in display order, entity/actor directories and any
entry animation. Only task events show a task ID and task hover card; other
events retain their entity or run destination. Unlinked events remain readable.
The main row stays 44px (comfortable) or 32px (compact), with 14px/16px text.
Added/removed issue references remain in a detail block below that row rather
than being dropped or clipped to fit. Narrow containers hide task ID first,
then timestamp in the smallest layout, matching task summaries.
Dashboard's Recent Activity opts in only while the design sandbox is on. The
sandbox also provides the same single-initial avatar policy as Design proposals
in Storybook, including account menus and other shared fallback avatars.
Turning proposals off restores the original rows and avatar policy.
### Task hover cards
**Design proposals → Components → Task hover card** documents the contextual
preview shared by task links and recent activity. The code names are
`IssueLinkQuicklook` (trigger/behavior) and `IssueQuicklookCard` (content).
Use the existing `Link` from `@/lib/router` for task links; set
`disableIssueQuicklook` when a surface should omit the preview. This choice
belongs to the caller, not a user preference. `IssueRow` and `TaskSummaryList`
remain opted out and reveal truncated text locally instead.
The refreshed card follows the Paper task-card reference: 280px width, 16px
padding, status/ID/timestamp metadata, a 14px semibold title clamped to two
lines, and a 12px description clamped to three lines. Project metadata is
omitted in this proposed appearance. The card uses background, border,
foreground and muted-foreground semantics plus the existing `--shadow-flyout`;
its geometry and typography live in the `--task-preview-*` token family.
It retains production hover intent, single-open behavior, focus, dismissal,
prefetch and access boundaries. The default appearance remains unchanged.
Story fixtures demonstrate the component and opt-out contract, not live access
checks; verify the sandbox's recent-activity links for integration review.
### Narrow task summaries
`TaskSummaryList` is the flat summary composition of the same refreshed
`IssueRow`, reviewed under **Design proposals → Components → Task list → Narrow**.
The **Playground** (stable story ID `tasks-row-design--specimen`) exposes row
density and container width both above the preview and in Storybook Controls.
It retains nesting and read/unread samples while varying these independently;
the fixed density and width stories remain review references.
The caller supplies ordered issues and an accessible list
label; `showIdentifier` and `showUpdated` can omit optional metadata. The list
uses regular muted read-style titles regardless of the source unread flag and
never marks a task read. It retains the 44px comfortable height, sm/16px type,
status tooltips, normal task navigation, hover, keyboard focus and cell-local
fade/marquee. It omits disclosure spacing, avatars, assignee, table headers,
column settings and collection state. Container queries hide Task ID below
28rem and Last updated below 20rem while keeping status/title readable without
horizontal scrolling. Existing row, spacing, type and divider tokens are reused.
Dashboard's Recent Tasks opts into this composition only while the design
sandbox is on. Data, ordering, limit and empty-state behavior stay with Dashboard;
the ordinary presentation remains available with the toggle off.
Agent overview's Recent Tasks uses the same summary in the sandbox, retaining
its participant-scoped query, updated ordering, six-task limit, more-task count
and See All link. The toggle restores its original rows as well.
### Sandbox button playground
**Design proposals / Components / Buttons / Playground** is a styling
workbench for the local design sandbox. All specimens use the shared proposal
theme. The live specimen, variant/state matrix and size matrix use the shared
Button API; contextual examples mount the sandbox's task toolbar, task list,
sidebar, pane controls, agent actions, conversation feedback, favorites, file
controls, status picker, attachment actions and relation expansion.
Label, variant, size, icon, interaction state and semantic surface controls
stay synchronized with Storybook args. Specimens measure actual dimensions,
padding, typography, radius, icon size and resolved colors. Hover, keyboard
focus and pressed previews derive from compiled component CSS under a
Storybook-only attribute; the app's Button gains no forced-state API. Disabled
and loading remain real props. Context actions use local fixtures.
The workbench has one full-width sandbox inventory. It does not show a
production iframe, comparison-layout controls, legacy-only components,
feature-gated priority controls or repository-wide source usage counts.
Playground is the single story in this grouping. These examples do not run
agents, persist uploads or establish backend behavior.
### Local integration sandbox
The development app can opt into **Design proposals only** with
`VITE_PAPERCLIP_DESIGN_SANDBOX=true`. The bottom-right **Design proposals: On/Off**
control persists a local comparison preference. Production builds and development
instances without this environment switch cannot activate it through browser
storage. This experiment does not enable other Storybook explorations.
When on, the app uses the same `.design-proposal-theme` marker and
`FlyoutAppearanceProvider` as the proposal stories. Document-wide token and portal
propagation is intentional: inspect ordinary pages for missing foundation mappings
and component variants. The desktop primary sidebar mounts `DesignSidebar` with
real recent-task data; existing layouts retain their route handling, contextual
sidebars, panels and dialogs. Mobile retains its existing drawer. No new global
component defaults are introduced. Toggle off removes the marker and restores
the existing sidebar, Tasks surface and menu appearance.
The resizable contextual pane uses `--design-page-aside-width` for its default
and double-click reset while the proposal is on (currently 320px). Sandbox
widths use separate `paperclip.design-proposal.properties-pane-width.*` storage
keys, so toggling off retains the ordinary 434px task-detail default and its
saved widths. This adapts the existing task-detail pane; it does not replace its
production content or maximize behavior with the layout-story fixtures.
`DesignSandboxTasks` adapts real company-scoped APIs to `TaskListToolbar`,
`TaskList`, `KanbanBoard` and `useTaskListCollection`. It imports no Storybook
fixtures. Creator names, projects, labels and viewer unread state come from APIs;
the ordinary organization list does not supply unread context, so a separate
server-filtered unread query supplies that set. Load-more explicitly limits
client search/filter/sort coverage to loaded tasks. Preferences use sandbox keys.
Task links, creation dialogs and board updates retain the existing app services.
This is an integration experiment, not permission, scale or release acceptance.
Dashboard metrics use `MetricCard appearance="card"` while the proposal is on.
This mounts the shared low-elevation `Card` with semantic background, border,
radius and padding, keeping the existing metric links and responsive grid.
The plain metric presentation remains the default outside the experiment.
The streamlined task-detail header follows the Paper title-block proposal while
the sandbox is on: a regular 24px title, followed by a metadata row grouping the
status picker with its monospaced task ID, project and people. The title remains
editable and the existing task-actions menu stays at the upper right. Typography
and spacing use the shared tokens; toggling off restores the ordinary header.
The local task detail also offers a **Thread preview** selector. DES-1 opens a
complete synthetic conversation matching the staging transcript layout; other
tasks start with their saved thread. Synthetic runner events pass through
`nativeRunEventsToTranscript` and `transcriptToTaskChatItems`. The preview uses
the shipped message bubbles, attached **Activity** history, working/elapsed/tool
status, chronological activity groups and composer takeovers. It does not import
the older chat-lab scenario inventory. The presentation flag follows the instance.
The selector also exposes native-runner turns, streaming commentary/final replies,
reasoning, tools, startup, queued messages, pause, failure, cancellation, timeout,
permissions/questions, result dispositions, provider activity, workspace changes
and usage. Native-runner and transcript layouts are named separately: runner
version alone does not determine which presentation a run uses. Individual
snapshots replay streaming text; **All states** is a labeled comparison gallery.
Stop, approval, question, feedback, resume and composer actions change local
presentation state only. **Show saved thread** restores the real task thread.
`threadPreview` preserves selection in the URL, including aliases for older links.
The fixture module is lazy-loaded only in the enabled development sandbox.
These styling snapshots do not run agents or prove provider/backend behavior.
Use the worktree-local isolated config and database. For a desktop visual review
without building the agent runner, the documented direct API/UI dev path is:
```sh
PAPERCLIP_CONFIG="$PWD/.paperclip/config.json" \
PAPERCLIP_HOME="$PWD/.paperclip-local" \
PAPERCLIP_INSTANCE_ID=design-system-sandbox \
PAPERCLIP_UI_DEV_MIDDLEWARE=true \
VITE_PAPERCLIP_DESIGN_SANDBOX=true \
HEARTBEAT_SCHEDULER_ENABLED=false \
PAPERCLIP_TELEMETRY_DISABLED=1 \
pnpm dev:server
```
The dark foundation is the review target; select dark mode in the account menu.
Local sample records are persisted through product APIs in the sandbox database,
not supplied by fetch mocks or copied from a live instance. Sample agents remain
paused. Agent execution is outside this visual sandbox.
## Enforcement (what "compliant" means for the extraction run)
- **Zero visual change is proven, not promised:** Storybook visual snapshots are baselined before any refactor, and all snapshots match baseline after it. A change that alters rendered output must be intentional and human-approved.
@@ -169,6 +838,111 @@ tokenize motion. Principles — reasoning only; values live in `ui/src/index.css
Agent Chat and regular task chats keep pending questions as compact “Unanswered question” entries at their original position in history. Dismissing the form or sending a newer user message clears it from the composer without resolving it. Questions never contribute to composer pending counts or navigation. Opening the history entry restores the original form and its draft; submitting later uses the same durable question response path. Actual permission reviews retain their permission checks.
## Canonical proposal toolbars
`Design proposals / Components / Toolbars` is the canonical toolbar review and
reuse home. Playground shows all demonstrated families; Tasks and Decisions
isolate their controls, and Narrow exercises wrapping. Reuse `TaskListToolbar` with
`useTaskListCollection` for proposed Tasks controls, `CollectionToolbar` for
collection context/search/controls/actions/feedback slots, and `DecisionsToolbar`
for decision filtering, grouping and sorting. These are distinct jobs; do not
copy their markup into new page-specific toolbars. Shared buttons, semantic
foundations, focus treatment and refreshed flyouts remain their dependencies.
The Decisions specimen mounts the production component used by
WhatNeedsMe and DecisionQueuePage. Preview creation callbacks only report
intent; menu and control state is local. Canonical proposal status establishes
the reference for continued styling and reuse, rather than certifying deployed
production behavior or migrating every existing page.
Skills and Artifacts are additional canonical toolbar references. Their shared
`SkillsToolbar` and `ArtifactsToolbar` presentations are consumed by both the
shipped pages and proposal stories; query, URL, permission and mutation state
stay in the pages. The Skills story simulates scanning and creation locally.
`Design proposals / Components / Tabs` defines two canonical filled sizes:
Compact uses `--text-xs` (12px) and a 24px item height; Standard uses
`--text-sm` (14px) and a 32px item height. Both use normal 400 weight,
muted inactive text, foreground selected text, rounded filled selection and
shared focus treatment. Standard follows the Paper tab bar: 10px horizontal
padding, 8px gaps and 4px list padding. Compact retains the artifact filter's
6px gaps. Sizes, colors, spacing and motion live in the token layer.
`TabBar` composes within Radix `Tabs` with matching content panels.
`TabFilters` uses an exclusive Radix ToggleGroup with radio semantics for
collection filters; it never clears the current choice. Both support roving
keyboard focus and disabled choices. `PageTabBar` adopts Standard and retains
its mobile select; connector source filters and the Artifacts toolbar use
Standard. Artifact type filters also support Compact for dense standalone uses. Segmented controllers and closable document tabs are separate
patterns. Tabs contains only Playground, Standard and Compact. Playground
compares the two sizes with local fixture state; narrow width remains a control.
Accepted toolbar foundation for Design Proposals and the opt-in sandbox:
- Use the shared `ToolbarSearch` and 44px control height, with one token-driven
border, radius and horizontal padding across Tasks, Decisions, Skills and
Artifacts. Adjacent toolbar controls and action groups use an 8px gap
(`--collection-toolbar-gap`); separate rows retain the 16px layout gap. Embedded search clear buttons and the inner halves of a segmented
layout switch retain their smaller geometry within a full-height control.
- Order controls as search, filter/sort/group/view, optional secondary actions,
then the primary action. Tasks is the exception: its view dropdown precedes
search because it also serves as a heading.
- Decisions includes a controlled search field on the desk and queue pages.
Search intersects active filters before grouping; it matches all entered words
across titles, task identifiers, summaries, agent, project and workspace names.
It searches the current decision list, not collapsed terminal-history curtains.
Further consolidation proposals, pending visual review:
- Move infrequent secondary creation actions into the primary creation menu
when that keeps the toolbar readable.
- Reuse CollectionToolbar slots while keeping domain-specific controls and
controllers separate. Preserve search and the primary action in narrow
layouts, with predictable wrapping or overflow for secondary controls.
- Use canonical Standard or Compact filled tabs for the compared label strips.
Keep panel-switching tab semantics separate from exclusive collection
filters. Closable document tabs retain their additional behavior.
### Segmented controller comparison
`Design proposals / Components / Segmented controllers` owns the segmented
comparison Playground, with Disabled and Narrow stories. It includes audit
scope, proposal and production task layouts, agent layouts, instruction and file
preview modes, secret ownership/value source, goal panels and model lanes.
FilePreviewModeToggle mounts directly; inline page controls use explicitly
labelled primitive compositions with local state. Page navigation and
collection filters remain in Tabs. This comparison does not migrate app styles
or certify backend behavior or keyboard accessibility.
Agents and Connectors compose the same `ToolbarSearch` with canonical Standard filled tabs. `AgentsToolbar` is shared by the roster page and
toolbar references; `ConnectorsToolbar` is shared by Browse and toolbar references. Keep search
first, status/source selection next, and view/actions last where present.
Connector source choices use radio semantics and preserve Browse's existing
automatic search expansion; they do not switch separate content panels.
Agents status navigation retains route-backed Tabs with its associated roster
panel. Agent search intersects the chosen status/built-in filter, matches name,
role and title words, and retains ancestor context for matching org-chart nodes.
Projects uses `ProjectsToolbar` for shared search, the existing name/updated/
created/target-date sort choices and direction toggling, and Add Project. Project
search matches all words across name and description before sorting and grouping.
Connector source tabs sit on a second row below search. Tasks keeps its view
dropdown before search; task view state and persistence are unchanged.
In the streamlined Skills area, `SkillsNavigationTabs` replaces the contextual
sidebar with Installed, Discover and My Skills links styled as canonical Standard
tabs beneath the toolbar. Studio and Sources keep these links beneath their own
header controls. Sources is a separate destination available through the Skills
New menu's Manage sources action; it is not a tab. The main company navigation
stays visible. Legacy Skills navigation retains its existing sidebar.
The streamlined My Skills landing (`/skills/studio`) mounts the same CompanySkills
collection, SkillsToolbar and DiscoveryGrid/SkillCard as Installed and Discover.
It keeps the existing installed-skill collection, defaults to Recently updated,
and opens cards directly in Studio. Search, source filtering, scanning, import,
folder creation and skill creation use the existing collection controllers.
Studio editor routes and the legacy recency landing keep their own layouts.
## Offline recovery
The service worker recovery page uses a centered character, a dark surface, and
+12
View File
@@ -50,6 +50,18 @@ Then **review the git diff and keep only the CSS-variable value changes** — th
- **Give it a story.** New visual surface = new Storybook story = automatic screenshot coverage forever.
- `pnpm check:token-gates` before you push. If a value genuinely can't be a token (third-party config, canvas fills, intentional one-off decoration on demo pages), it goes on the allowlist **with an inline comment saying why**.
## Proposed task collections
For work explicitly implementing the staged design refresh, review **Design
proposals → Pages → Tasks** and the component stories. Compose shared UI with
`useTaskListCollection` (`ui/src/hooks/useTaskListCollection.ts`) rather than
reimplementing search, creator identity mapping, views, filter transitions or
hierarchy expansion. Its `visibleItems` include parent context for the list;
boards use `matchingItems`. Pass its namespaced `creators` into the filter menu.
The caller supplies accessible data and handles API mutations. See `DESIGN.md`
for the contract and trial acceptance cases; the proposed controller is not yet
wired into production routes.
## Recipe 4 — you changed something and snapshots failed
That's the system working. Two cases:
+2 -2
View File
@@ -22,11 +22,11 @@ Agent access: [companion](../agent-access-map/skills.md).
### `library-sources`
Open `/skills`, Discover, or `/skills/sources`; project import is available through the library’s import flow.
Open `/skills`, Discover, or `/skills/sources`; project import is available through the library’s import flow. In Streamlined UI, Installed, Discover and My Skills are route links styled as tabs beneath the toolbar, with no secondary navigation rail. Sources stays separate under New → Manage sources. Legacy UI retains the Skills sidebar.
### `studio`
Use New skill or `/skills/studio/:skillId`; writable copies and source-backed originals differ.
Use New skill or `/skills/studio/:skillId`; writable copies and source-backed originals differ. In Streamlined UI, `/skills/studio` uses the shared Skills toolbar and card collection with My Skills active and Recently updated sorting; opening a card enters Studio.
Direct authoring starts at `/skills/studio/new`: fill **Name** and **Slug**, then
**Create skill**. Authoring/save/reload requires no model credential; running a
Studio test is separate.
+19
View File
@@ -31,6 +31,8 @@ const meta = {
primary home; others cross-list the same suite. An empty array requires an
`exception` explaining the intentional scope. Shared foundations may use
`home: "foundations"` with an exception to keep their existing location and links.
Staged design work may use `home: "design-proposals"`, with `status: "proposed"`
and an exception explaining why it is staged separately from production references.
The metadata must be literal; place it after any other parameter spreads.
| Field | Values and purpose |
@@ -55,6 +57,23 @@ the recipe's section and H1 title, followed by the suite name. `/` in feature
labels becomes `, ` in the sidebar. Preserve explicit CSF IDs across title/folder
changes. Compare the built `index.json` when changing navigation.
**Design proposals** is a separate top-level review section. Its stories live in
`ui/storybook/stories/design-proposals/`, with titles beginning `Design proposals/`.
Group these titles by **Foundations** (tokens), **Components** (reusable UI),
**Layouts** (page structures), **Pages** (complete screens), **Patterns** (shared
behavior) and **Guidelines** (usage and contribution rules), in that order.
Create a group when there are stories for it; do not add empty placeholders.
For example, color tokens belong in `Design proposals/Foundations/Colors (dark mode)`,
the task table in `Design proposals/Components/Task list`, and its complete page
in `Design proposals/Pages/Tasks`. Preserve explicit story IDs and exports when
reorganizing so links and browser tests remain valid.
Retain capability references so proposals remain discoverable from the feature
guide, but never count them as current production examples. Keep using shared
components and tokens rather than creating a parallel design implementation.
When a proposal is reviewed and adopted in the app, move its story into the usual
feature or Foundations home, update its classification and staging metadata, and
preserve the explicit CSF ID and exports so review links remain valid.
The generator discovers feature recipes and story annotations. It assembles:
- `ui/storybook/generated/catalog.json` and `feature-index.json`.
+80
View File
@@ -0,0 +1,80 @@
import { readFileSync, readdirSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("..", import.meta.url));
const require = createRequire(path.join(root, "ui/package.json"));
const storybookRequire = createRequire(require.resolve("storybook/internal/csf-tools"));
const { parse } = storybookRequire("@babel/parser");
const traverse = storybookRequire("@babel/traverse").default;
function canonicalImport(source, file) {
if (source === "@/components/ui/button") return true;
return source.startsWith(".") && path.posix.normalize(path.posix.join(path.posix.dirname(file), source))
.replace(/\.[jt]sx?$/, "") === "ui/src/components/ui/button";
}
function propValue(attributes, name, fallback) {
const explicit = attributes.find(attribute => attribute.type === "JSXAttribute" && attribute.name.name === name);
if (!explicit) return attributes.some(attribute => attribute.type === "JSXSpreadAttribute") ? "dynamic / spread" : fallback;
// A subsequent spread may override even an explicit literal.
if (attributes.slice(attributes.indexOf(explicit) + 1).some(attribute => attribute.type === "JSXSpreadAttribute")) return "dynamic / spread";
const value = explicit.value?.type === "JSXExpressionContainer" ? explicit.value.expression : explicit.value;
return value?.type === "StringLiteral" ? value.value : "dynamic";
}
/** Count source call sites, never rendered instances or user activity. */
export function buttonReferences(source, file, scope) {
const ast = parse(source, { sourceType: "module", plugins: ["typescript", "jsx"] });
const entries = [];
traverse(ast, {
JSXOpeningElement(element) {
const name = element.node.name;
const local = name.type === "JSXIdentifier" ? name.name : name.type === "JSXMemberExpression" && name.object.type === "JSXIdentifier" ? name.object.name : null;
if (!local) return;
const binding = element.scope.getBinding(local);
if (!binding || !canonicalImport(binding.path.parent.source?.value ?? "", file)) return;
const importedButton = binding.path.isImportSpecifier() && (binding.path.node.imported.name ?? binding.path.node.imported.value) === "Button" && name.type === "JSXIdentifier";
const namespaceButton = binding.path.isImportNamespaceSpecifier() && name.type === "JSXMemberExpression" && name.property.name === "Button";
if (!importedButton && !namespaceButton) return;
entries.push({ file, line: element.node.loc.start.line, scope,
variant: propValue(element.node.attributes, "variant", "default"),
size: propValue(element.node.attributes, "size", "default"),
});
},
});
return entries;
}
function walk(directory) {
return readdirSync(path.join(root, directory), { withFileTypes: true }).flatMap(entry => {
const file = `${directory}/${entry.name}`;
return entry.isDirectory() ? walk(file) : /\.tsx$/.test(file) && !/\.(test|spec|stories)\.tsx$/.test(file) ? [file] : [];
});
}
function inventory() {
const roots = [["ui/src", "app"]];
const entries = roots.flatMap(([directory, scope]) => walk(directory).flatMap(file => {
const source = readFileSync(path.join(root, file), "utf8");
// Parsing is unnecessary for modules that cannot import the subject.
return /\bButton\b/.test(source) ? buttonReferences(source, file, scope) : [];
}));
return { entries: entries.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line) };
}
/** Storybook-only module: publish counts and locations, without bundling app source text. */
export function storybookButtonUsage() {
const id = "virtual:paperclip-button-usage";
return {
name: "paperclip-button-usage",
resolveId(value) { if (value === id) return `\0${id}`; },
load(value) { if (value === `\0${id}`) return `export default ${JSON.stringify(inventory())};`; },
handleHotUpdate(context) {
if (!/\/ui\/src\/.*\.tsx$/.test(context.file)) return;
const module = context.server.moduleGraph.getModuleById(`\0${id}`);
if (module) { context.server.moduleGraph.invalidateModule(module); return [...context.modules, module]; }
},
};
}
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buttonReferences } from "./storybook-button-usage.mjs";
test("button usage follows canonical imports and aliases, excluding native and unrelated buttons", () => {
const entries = buttonReferences(`
import { Button as Action } from '@/components/ui/button';
import * as UI from './ui/button';
import { Button } from './OtherButton';
export const View = () => <><Action variant="outline" size="sm" /><UI.Button /><Button /><button /></>;
`, "ui/src/components/Example.tsx", "app");
assert.equal(entries.length, 2);
assert.deepEqual(entries.map(({ variant, size, scope }) => ({ variant, size, scope })), [
{ variant: "outline", size: "sm", scope: "app" }, { variant: "default", size: "default", scope: "app" },
]);
});
test("dynamic props and spread precedence are not reported as known defaults", () => {
const entries = buttonReferences(`
import { Button } from '@/components/ui/button';
export const View = () => <><Button {...props} /><Button variant={style} /><Button variant="cta" {...props} /><Button {...props} variant="ghost" /></>;
`, "ui/src/pages/Example.tsx", "app");
assert.deepEqual(entries.map(entry => entry.variant), ["dynamic / spread", "dynamic", "dynamic / spread", "ghost"]);
assert.deepEqual(entries.map(entry => entry.size), ["dynamic / spread", "default", "dynamic / spread", "dynamic / spread"]);
});
+13 -5
View File
@@ -14,6 +14,7 @@ const statuses = ["current", "proposed", "historical", "mixed", "unreviewed"];
const implementations = ["production", "prototype", "mixed", "guide", "unreviewed"];
const boundaries = ["fixtures", "fixture-server", "static", "unreviewed"];
const annotationFields = new Set(["capabilities", "status", "implementation", "boundary", "notes", "home", "exception", "replacement"]);
const specialHomes = { overview: "Overview", "design-proposals": "Design proposals", foundations: "Foundations" };
function files(root, dir) {
return readdirSync(path.join(root, dir), { withFileTypes: true }).flatMap(entry => {
@@ -74,7 +75,7 @@ export function catalogFromStories(stories, index) {
const navigation = new Map(index.sections.flatMap(section => section.features.map(feature => [feature.id, `${sidebarLabel(section.title)}/${sidebarLabel(feature.title)}`])));
return { version: 2, entries: stories.map(story => {
const entry = { status: "unreviewed", implementation: "unreviewed", boundary: "unreviewed", notes: "", ...story.annotation, file: story.file, stableId: story.metaId, sidebarTitle: story.title };
const prefix = navigation.get(storyHome(entry)) ?? ({ overview: "Overview", foundations: "Foundations" })[storyHome(entry)];
const prefix = navigation.get(storyHome(entry)) ?? specialHomes[storyHome(entry)];
return { ...entry, purpose: story.title?.startsWith(`${prefix}/`) ? story.title.slice(prefix.length + 1) : story.title };
}) };
}
@@ -91,14 +92,18 @@ export function validateCatalog(catalog, stories, index) {
if (!boundaries.includes(entry.boundary)) errors.push(`${label}: invalid data boundary`);
if (typeof entry.notes !== "string") errors.push(`${label}: notes must be text`);
if (!Array.isArray(entry.capabilities) || entry.capabilities.some(ref => typeof ref !== "string")) { errors.push(`${label}: capabilities must be an array of IDs`); continue; }
if (entry.home !== undefined && entry.home !== "foundations") errors.push(`${label}: only shared foundations may override the capability home`);
if (entry.home !== undefined && !["foundations", "design-proposals"].includes(entry.home)) errors.push(`${label}: only shared foundations and design proposals may override the capability home`);
if (entry.exception !== undefined && (typeof entry.exception !== "string" || !entry.exception.trim())) errors.push(`${label}: exception must be nonempty text`);
if (entry.home === "foundations" && !entry.exception) errors.push(`${label}: foundation home needs an explicit reason`);
if (entry.home === "design-proposals") {
if (entry.status !== "proposed") errors.push(`${label}: design proposal home requires proposed status`);
if (!entry.exception) errors.push(`${label}: design proposal home needs an explicit staging reason`);
}
for (const ref of entry.capabilities) if (!refs.has(ref)) errors.push(`${label}: unknown feature capability ${ref}`);
if (!entry.capabilities.length && !entry.exception) errors.push(`${label}: unmapped story needs an explicit reason`);
if (entry.capabilities.length !== new Set(entry.capabilities).size) errors.push(`${label}: duplicate capability references`);
const area = storyHome(entry);
const prefix = navigation.get(area) ?? ({ overview: "Overview", foundations: "Foundations" })[area];
const prefix = navigation.get(area) ?? specialHomes[area];
if (!label.startsWith(`${storyRoot}/${area}/`)) errors.push(`${label}: story must live in its primary feature folder ${area}`);
if (!prefix || typeof entry.sidebarTitle !== "string" || !entry.sidebarTitle.startsWith(`${prefix}/`)) errors.push(`${label}: sidebar title must match its feature-map home`);
if (typeof entry.stableId !== "string" || !entry.stableId.trim()) errors.push(`${label}: explicit meta ID is required`);
@@ -124,7 +129,10 @@ export function renderIndex(catalog, stories, index) {
"[Contract and maintenance](../storybook-map/README.md) · [Verification routes](verification.md)", "",
`${stories.length} story files; ${stories.reduce((n, story) => n + story.ids.length, 0)} indexed stories. Counts describe inventory, not passed tests or complete feature coverage.`, "",
"Status describes product alignment; implementation describes the rendered subject; boundary describes simulated data. Limitations are authored alongside each story. Classification is not executed evidence. Related capabilities cross-list one canonical story file.", ""];
const homes = index.sections.flatMap(section => section.features.map(feature => ({ ...feature, section: section.title })));
const homes = [
{ id: "design-proposals", title: "Proposed design system", section: "Design proposals", description: "Staged design work for review. These examples retain capability references but do not count as current product coverage." },
...index.sections.flatMap(section => section.features.map(feature => ({ ...feature, section: section.title }))),
];
homes.push({ id: "foundations-and-guides", title: "Shared foundations and guides", section: "Foundations", description: "Shared components and contributor guidance outside the product feature index." });
let currentSection;
for (const feature of homes) {
@@ -134,7 +142,7 @@ export function renderIndex(catalog, stories, index) {
lines.push(`## ${feature.section}`, "");
currentSection = feature.section;
}
lines.push(`### ${feature.title}`, "", feature.description, "", ...(area !== "foundations-and-guides" ? [`[Feature recipe](../feature-map/${area}.md)`, ""] : []));
lines.push(`### ${feature.title}`, "", feature.description, "", ...(!["foundations-and-guides", "design-proposals"].includes(area) ? [`[Feature recipe](../feature-map/${area}.md)`, ""] : []));
if (!entries.length) lines.push("No primary story suite. Check capability lookup below for related examples; this does not imply a missing product feature.", "");
else lines.push(
"| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note |",
+43 -1
View File
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, rmSync } from "node:fs";
import path from "node:path";
import { test } from "node:test";
import { catalogFromStories, generateStorybook, renderVerification, scanStories, validateCatalog } from "./storybook-catalog.mjs";
import { catalogFromStories, generateStorybook, renderIndex, renderVerification, scanStories, validateCatalog } from "./storybook-catalog.mjs";
import { featureIndex } from "./feature-map.mjs";
import { mapFixture } from "./map-test-fixture.mjs";
@@ -72,6 +72,48 @@ test("foundations preserve home and cross-capability references", t => {
assert.match(inspect(f).errors.join("\n"), /foundation home needs an explicit reason/);
});
test("design proposals keep stable IDs and capability links in a separate catalog section without filling coverage gaps", t => {
const f = mapFixture(t);
const originalIds = scanStories(f.root)[0].ids;
rmSync(path.join(f.root, f.file));
f.story("task", {
home: "design-proposals",
status: "proposed",
capabilities: ["tasks/create", "tasks/discussion"],
implementation: "production",
boundary: "fixtures",
exception: "Opt-in task row refresh awaiting design review and application rollout.",
}, { area: "design-proposals", title: "Design proposals/Tasks/Task row" });
const { catalog, stories, index, errors } = inspect(f);
assert.deepEqual(errors, []);
assert.deepEqual(stories[0].ids, originalIds);
assert.equal(catalog.entries[0].home, "design-proposals");
assert.equal(catalog.entries[0].purpose, "Tasks/Task row");
const report = renderIndex(catalog, stories, index);
const proposalSection = report.split("## Design proposals\n")[1].split("## Work\n")[0];
assert.match(proposalSection, /Design proposals\/Tasks\/Task row/);
assert.doesNotMatch(report, /feature-map\/design-proposals\.md/);
assert.match(report, /- `tasks\/create`: .*\(proposed\)/);
assert.match(report, /- `tasks\/discussion`: .*\(proposed\)/);
assert.match(renderVerification(catalog, index), /2 without a current, mixed or unreviewed example/);
});
test("design proposal homes require proposed status, a staging reason and their own navigation", t => {
const f = mapFixture(t);
const metadata = { home: "design-proposals", status: "proposed", capabilities: ["tasks/create"], exception: "Staged refresh for review." };
const options = { area: "design-proposals", title: "Design proposals/Tasks/Task row" };
for (const status of ["current", "mixed", "unreviewed", "historical"]) {
f.story("proposal", { ...metadata, status }, options);
assert.match(inspect(f).errors.join("\n"), /design proposal home requires proposed status/);
}
f.story("proposal", { ...metadata, exception: undefined }, options);
assert.match(inspect(f).errors.join("\n"), /design proposal home needs an explicit staging reason/);
f.story("proposal", metadata, { ...options, title: "Work/Tasks/Task row" });
assert.match(inspect(f).errors.join("\n"), /sidebar title/);
f.story("misplaced", metadata, { title: options.title });
assert.match(inspect(f).errors.join("\n"), /primary feature folder design-proposals/);
});
test("duplicate IDs, missing replacement references and collisions with generated guides fail", t => {
const f = mapFixture(t);
f.story("other", { capabilities: ["tasks/create"] }, { id: "stable-task" });
+1 -1
View File
@@ -8,7 +8,7 @@ export const generatedRoot = "ui/storybook/generated";
export const generatedPreview = "ui/storybook/.storybook/preview.tsx";
export function featureOrder(index) {
return ["Overview", ...index.sections.flatMap(section => [sidebarLabel(section.title),
return ["Overview", "Design proposals", ["Foundations", "Components", "Layouts", "Pages", "AI Generated Test Pages", "Patterns", "Guidelines"], ...index.sections.flatMap(section => [sidebarLabel(section.title),
section.features.flatMap(feature => [sidebarLabel(feature.title), ["Overview", "*"]]),
]), "Foundations"];
}
+1 -1
View File
@@ -16,7 +16,7 @@ test("recipes own discovery, names, section order and multiline capability descr
assert.deepEqual(index.sections.map(section => section.title), ["Work", "Connections"]);
assert.deepEqual(index.sections.flatMap(section => section.features.map(feature => feature.id)), ["tasks", "goals", "apps"]);
assert.equal(index.sections[0].features[0].capabilities[0].description, "Create work; retain its description.");
assert.deepEqual(featureOrder(index), ["Overview", "Work", ["Tasks", ["Overview", "*"], "Goals", ["Overview", "*"]], "Connections", ["Apps", ["Overview", "*"]], "Foundations"]);
assert.deepEqual(featureOrder(index), ["Overview", "Design proposals", ["Foundations", "Components", "Layouts", "Pages", "AI Generated Test Pages", "Patterns", "Guidelines"], "Work", ["Tasks", ["Overview", "*"], "Goals", ["Overview", "*"]], "Connections", ["Apps", ["Overview", "*"]], "Foundations"]);
});
test("equal local orders preserve independently added features using their stable filename", t => {
@@ -0,0 +1,71 @@
import { expect, test } from "@playwright/test";
test.use({ viewport: { width: 1200, height: 900 } });
async function openActivity(page: import("@playwright/test").Page, story = "playground") {
await page.goto(`/iframe.html?id=design-activity-list--${story}&viewMode=story&globals=theme:dark`);
await expect(page.getByRole("list", { name: "Recent activity", exact: true })).toBeVisible();
await page.evaluate(() => document.fonts.ready);
}
test("activity shares task densities and responsive metadata without inventing task IDs", async ({ page }, testInfo) => {
await openActivity(page);
const list = page.getByRole("list", { name: "Recent activity", exact: true });
const rows = list.locator('[data-slot="activity-row"]');
await expect(rows).toHaveCount(6);
await expect(list.locator('[data-slot="avatar-fallback"]')).toHaveText(["R", "R", "R", "R", "S"]);
await expect(rows.nth(1)).toHaveAttribute("href", "/PAP/agents/agent-codex");
await expect(rows.nth(2)).toHaveAttribute("href", /\/PAP\/projects\//);
await expect(rows.nth(3)).toHaveAttribute("href", "/PAP/approvals/approval-budget");
await expect(rows.nth(4)).toHaveAttribute("href", "/PAP/agents/agent-codex/runs/run-storybook");
await expect(rows.nth(5)).not.toHaveAttribute("href");
await expect(list.locator('[data-slot="task-row-identifier"]')).toHaveCount(1);
for (const density of ["Comfortable (44px)", "Compact (32px)"]) {
await page.getByRole("group", { name: "Row density", exact: true }).getByRole("button", { name: density, exact: true }).click();
for (const width of ["Standard", "Narrow", "Smallest"]) {
await page.getByRole("group", { name: "Container width", exact: true }).getByRole("button", { name: width, exact: true }).click();
for (const row of await rows.all()) {
await expect(row).toHaveCSS("height", density.startsWith("Compact") ? "32px" : "44px");
await expect(row.locator('[data-slot="task-row-title"]')).toHaveCSS("font-size", "14px");
await expect(row.locator('[data-slot="task-row-title"]')).toHaveCSS("line-height", "16px");
await expect(row.locator('[data-slot="task-row-timestamp"]')).toBeVisible({ visible: width !== "Smallest" });
}
await expect(rows.first().locator('[data-slot="task-row-identifier"]')).toBeVisible({ visible: width === "Standard" });
expect(await list.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
}
}
await page.getByRole("button", { name: "Narrow", exact: true }).click();
await page.screenshot({ path: testInfo.outputPath("activity-list-narrow.png"), fullPage: true });
});
test("activity keeps task hover cards and cell-local fade/marquee while non-task links stay independent", async ({ page }) => {
await openActivity(page, "narrow");
const rows = page.locator('[data-slot="activity-row"]');
const row = rows.first();
const title = row.locator('[data-slot="task-row-title"]');
await expect(title).toHaveAttribute("data-truncated", "true");
expect(await title.locator(".cell-marquee-viewport").evaluate(element => getComputedStyle(element).maskImage)).toContain("linear-gradient");
const color = await title.evaluate(element => getComputedStyle(element).color);
await title.hover();
await expect(title).toHaveAttribute("data-revealing", "true");
expect(await title.evaluate(element => getComputedStyle(element).color)).not.toBe(color);
const popup = page.locator('[data-quicklook][data-slot="popover-content"]');
await expect(popup).toBeVisible();
await expect(popup.locator('[data-slot="task-preview-id"]')).toHaveText("PAP-431");
await row.locator('[data-slot="task-row-timestamp"]').hover();
await expect(title).not.toHaveAttribute("data-revealing");
await rows.nth(1).hover();
await expect(popup).toBeHidden();
await expect(title).not.toHaveAttribute("data-revealing");
await row.focus();
await expect(popup).toBeVisible();
await page.keyboard.press("Escape");
await expect(popup).toBeHidden();
});
test("reference changes remain available below the fixed-height activity row", async ({ page }) => {
await openActivity(page, "reference-changes");
await expect(page.getByLabel("Added references", { exact: true })).toBeVisible();
await expect(page.getByLabel("Removed references", { exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Task PAP-400: Superseded connector plan", exact: true })).toBeVisible();
await expect(page.locator('[data-slot="activity-row"]').first()).toHaveCSS("height", "44px");
});
@@ -0,0 +1,244 @@
import { expect, test, type Page } from "@playwright/test";
test.use({ viewport: { width: 1200, height: 800 } });
async function openShell(page: Page, story: string) {
await page.goto(`/iframe.html?id=design-app-shell--${story}&viewMode=story&globals=theme:dark`);
await expect(page.getByTestId("design-page-shell")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
}
test("two and three panes share the sidebar, maintain boundaries, and fit narrower desktops", async ({ page }, testInfo) => {
await openShell(page, "two-panes");
const main = page.getByRole("main");
const sidebar = page.locator(".design-sidebar");
const resize = page.getByRole("separator", { name: "Resize sidebar", exact: true });
await expect(page.getByRole("complementary")).toHaveCount(0);
await expect(sidebar).toHaveCSS("width", "240px");
expect((await main.boundingBox())!.width).toBe(960);
await resize.press("ArrowRight");
await expect(resize).toHaveAttribute("aria-valuenow", "256");
expect((await main.boundingBox())!.width).toBe(944);
await page.reload();
await expect(resize).toHaveAttribute("aria-valuenow", "256");
await page.screenshot({ path: testInfo.outputPath("app-shell-two-panes.png") });
await openShell(page, "three-panes");
const right = page.getByRole("complementary", { name: "Right sidebar", exact: true });
await expect(resize).toHaveAttribute("aria-valuenow", "240");
await expect(right).toHaveCSS("width", "320px");
await expect(right).toHaveCSS("background-color", "rgb(26, 26, 26)");
await expect(right).toHaveCSS("border-left-width", "1px");
await expect(right).toHaveCSS("border-left-color", "oklch(1 0 0 / 0.1)");
await expect(page.locator(".design-page-main")).toHaveCSS("background-color", "rgb(26, 26, 26)");
await expect(sidebar).toHaveCSS("background-color", "oklch(0.269 0 0)");
const mainBox = (await main.boundingBox())!;
const rightBox = (await right.boundingBox())!;
expect(mainBox.x + mainBox.width).toBe(rightBox.x);
expect(rightBox.x + rightBox.width).toBe(1200);
await page.screenshot({ path: testInfo.outputPath("app-shell-three-panes.png") });
await page.setViewportSize({ width: 960, height: 800 });
await expect(right).toHaveCSS("width", "288px");
expect((await main.boundingBox())!.width).toBe(432);
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false);
await page.screenshot({ path: testInfo.outputPath("app-shell-three-panes-narrow.png") });
const skip = page.getByRole("link", { name: "Skip to main content" });
await skip.focus();
await skip.press("Enter");
await expect(main).toBeFocused();
});
test("main and right content scroll independently while headers and sidebar stay in place", async ({ page }) => {
await openShell(page, "independent-scrolling");
const main = page.getByRole("main");
const right = page.getByRole("region", { name: "Right sidebar content", exact: true });
const sidebar = page.locator(".design-sidebar");
const header = page.getByRole("heading", { name: "App shell", exact: true });
const rightHeader = page.getByRole("complementary", { name: "Right sidebar", exact: true }).locator("header");
const originalHeaderY = (await header.boundingBox())!.y;
const originalRightHeaderY = (await rightHeader.boundingBox())!.y;
const originalSidebarY = (await sidebar.boundingBox())!.y;
for (const pane of [main, right]) {
await expect(pane).toHaveCSS("scrollbar-color", "rgba(0, 0, 0, 0) rgba(0, 0, 0, 0)");
}
await main.hover();
await page.mouse.wheel(0, 600);
await expect.poll(() => main.evaluate(element => element.scrollTop)).toBeGreaterThan(0);
await expect(main).toHaveAttribute("data-scroll-active", "true");
expect(await right.evaluate(element => element.scrollTop)).toBe(0);
// Focus a pane with overflow and scroll it using the keyboard.
await right.focus();
await right.press("PageDown");
await expect.poll(() => right.evaluate(element => element.scrollTop)).toBeGreaterThan(0);
await expect(right).toHaveAttribute("data-scroll-active", "true");
const mainScroll = await main.evaluate(element => element.scrollTop);
await right.hover();
await page.mouse.wheel(0, 400);
expect(await main.evaluate(element => element.scrollTop)).toBe(mainScroll);
expect((await header.boundingBox())!.y).toBe(originalHeaderY);
expect((await rightHeader.boundingBox())!.y).toBe(originalRightHeaderY);
expect((await sidebar.boundingBox())!.y).toBe(originalSidebarY);
expect(await page.evaluate(() => window.scrollY)).toBe(0);
for (const pane of [main, right]) {
await expect(pane).not.toHaveAttribute("data-scroll-active", "true");
await expect(pane).toHaveCSS("scrollbar-color", "rgba(0, 0, 0, 0) rgba(0, 0, 0, 0)");
}
});
test("right divider matches the left highlight, resizes in both directions, persists and resets", async ({ page }, testInfo) => {
await openShell(page, "three-panes");
const pane = page.getByRole("complementary", { name: "Right sidebar", exact: true });
const resize = page.getByRole("separator", { name: "Resize right sidebar", exact: true });
const left = page.getByRole("separator", { name: "Resize sidebar", exact: true });
await expect(resize).toHaveAttribute("aria-valuenow", "320");
await expect(resize).toHaveCSS("width", "8px");
const highlight = await left.evaluate(element => getComputedStyle(element).getPropertyValue("--ring").trim());
expect(highlight).toBe("oklch(0.556 0 0)");
expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--ring").trim())).toBe("oklch(0.985 0 0)");
await left.hover();
await expect(left.locator("div")).toHaveCSS("background-color", highlight);
await resize.hover();
await expect(resize.locator("div")).toHaveCSS("background-color", highlight);
const box = (await resize.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + 160);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 - 120, box.y + 160, { steps: 8 });
await expect(pane).toHaveCSS("width", "440px");
await expect(resize).toHaveAttribute("data-dragging", "");
await page.mouse.up();
await expect(resize).not.toHaveAttribute("data-dragging", "");
await page.reload();
await expect(pane).toHaveCSS("width", "440px");
await resize.press("ArrowRight");
await expect(pane).toHaveCSS("width", "424px");
await resize.press("ArrowLeft");
await expect(pane).toHaveCSS("width", "440px");
await page.getByRole("button", { name: "Maximize side panel", exact: true }).click();
await expect(resize).toHaveCount(0);
await page.getByRole("button", { name: "Restore side panel", exact: true }).click();
await expect(pane).toHaveCSS("width", "440px");
await page.setViewportSize({ width: 960, height: 800 });
await expect(pane).toHaveCSS("width", "400px");
expect((await page.getByRole("main").boundingBox())!.width).toBe(320);
await resize.dblclick();
await expect(pane).toHaveCSS("width", "288px");
await page.setViewportSize({ width: 1200, height: 800 });
await expect(pane).toHaveCSS("width", "320px");
await page.reload();
await expect(pane).toHaveCSS("width", "320px");
await resize.press("Home");
await expect(pane).toHaveCSS("width", "260px");
await resize.press("End");
await expect(pane).toHaveCSS("width", "640px");
await resize.dblclick();
await expect(pane).toHaveCSS("width", "320px");
await expect(left).toHaveAttribute("aria-valuenow", "240");
await resize.hover();
await page.screenshot({ path: testInfo.outputPath("app-shell-right-resize-highlight.png") });
});
test("both pane dividers animate a double-click reset while manual sizing stays immediate", async ({ page }) => {
await openShell(page, "three-panes");
const left = page.getByRole("separator", { name: "Resize sidebar", exact: true });
const leftShell = page.locator(".design-page-navigation");
const leftPanel = leftShell.locator(".sidebar-shell-panel");
const right = page.getByRole("separator", { name: "Resize right sidebar", exact: true });
const rightPane = page.getByRole("complementary", { name: "Right sidebar", exact: true });
// Observe real CSS transitions, so this proves motion rather than only its end state.
for (const [pane, property] of [[leftShell, "width"], [leftPanel, "width"], [rightPane, "flex-basis"]] as const) {
await pane.evaluate((element, propertyName) => {
element.addEventListener("transitionrun", event => {
const transition = event as TransitionEvent;
if (event.target !== element || transition.propertyName !== propertyName) return;
element.setAttribute("data-observed-reset-transition", propertyName);
element.setAttribute("data-observed-reset-duration", getComputedStyle(element).transitionDuration);
});
}, property);
}
await left.press("End");
await expect(leftShell).toHaveCSS("width", "420px");
await expect(leftShell).toHaveCSS("transition-duration", "0s");
await left.dblclick();
for (const pane of [leftShell, leftPanel]) {
await expect(pane).toHaveAttribute("data-observed-reset-transition", "width");
await expect(pane).toHaveAttribute("data-observed-reset-duration", "0.2s");
await expect(pane).toHaveCSS("width", "240px");
}
await expect(leftShell).not.toHaveAttribute("data-width-resetting", "true");
await right.press("End");
await expect(rightPane).toHaveCSS("width", "640px");
await expect(rightPane).toHaveCSS("transition-duration", "0s");
await right.dblclick();
await expect(rightPane).toHaveAttribute("data-observed-reset-transition", "flex-basis");
await expect(rightPane).toHaveAttribute("data-observed-reset-duration", "0.2s");
await expect(rightPane).toHaveCSS("width", "320px");
await expect(rightPane).not.toHaveAttribute("data-width-resetting", "true");
});
test("reduced motion resets both panes instantly", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await openShell(page, "three-panes");
for (const [name, selector, width] of [
["Resize sidebar", ".design-page-navigation", "240px"],
["Resize right sidebar", ".design-page-aside", "320px"],
]) {
const resize = page.getByRole("separator", { name, exact: true });
const pane = page.locator(selector);
await resize.press("End");
await resize.dblclick();
await expect(pane).toHaveCSS("width", width);
await expect(pane).toHaveCSS("transition-duration", "0s");
expect(await pane.evaluate(element => element.getAnimations().length)).toBe(0);
}
});
test("production pane controls add and close tabs, maximize, restore, and reopen the pane", async ({ page }, testInfo) => {
await openShell(page, "three-panes");
const right = page.getByRole("complementary", { name: "Right sidebar", exact: true });
await expect(right.getByRole("tab")).toHaveCount(4);
await expect(right.getByRole("tabpanel")).toHaveAccessibleName("Properties");
await page.getByRole("button", { name: "Maximize side panel", exact: true }).click();
await expect(right).toHaveCSS("width", "960px");
await expect(page.getByRole("main")).toHaveCount(0);
await expect(page.locator(".design-sidebar")).toBeVisible();
await right.getByRole("tab", { name: "Tasks", exact: true }).click();
await expect(right.getByRole("tabpanel")).toHaveAccessibleName("Tasks");
await right.getByRole("tab", { name: "Tasks", exact: true }).press("ArrowRight");
await expect(right.getByRole("tabpanel")).toHaveAccessibleName("Plan");
await right.getByRole("tab", { name: "Tasks", exact: true }).click();
await right.getByRole("button", { name: "Close Tasks", exact: true }).click();
await expect(right.getByRole("tab", { name: "Tasks", exact: true })).toHaveCount(0);
await expect(right.getByRole("tabpanel")).toHaveAccessibleName("Plan");
await right.getByRole("button", { name: "Open a new tab", exact: true }).click();
await page.getByRole("option", { name: "Tasks", exact: true }).click();
await expect(right.getByRole("tabpanel")).toHaveAccessibleName("Tasks");
await expect(right.getByRole("tab")).toHaveCount(4);
await right.getByRole("tab", { name: "Tasks", exact: true }).hover();
await right.getByRole("button", { name: "Close Tasks", exact: true }).click();
await expect(right.getByRole("tabpanel")).toHaveAccessibleName("Artifacts");
await page.getByRole("button", { name: "Restore side panel", exact: true }).click();
await expect(right).toHaveCSS("width", "320px");
await expect(page.getByRole("main")).toBeVisible();
await page.getByRole("button", { name: "Close side panel", exact: true }).click();
await expect(right).toHaveCount(0);
const toggle = page.getByRole("button", { name: "Toggle side panel", exact: true });
await expect(toggle).toBeFocused();
await toggle.click();
await expect(right.getByRole("tabpanel")).toHaveAccessibleName("Artifacts");
await expect(right.getByRole("tab")).toHaveCount(3);
// Closing the final tab leaves the production launcher available for recovery.
for (const label of ["Artifacts", "Plan", "Properties"]) {
const tab = right.getByRole("tab", { name: label, exact: true });
await tab.click();
await right.getByRole("button", { name: `Close ${label}`, exact: true }).click();
}
await expect(right.getByRole("tabpanel")).toHaveCount(0);
await right.getByRole("option", { name: "Properties", exact: true }).click();
await expect(right.getByRole("tabpanel")).toHaveAccessibleName("Properties");
await page.screenshot({ path: testInfo.outputPath("app-shell-production-pane-controls.png") });
});
@@ -0,0 +1,80 @@
import { expect, test, type Page } from "@playwright/test";
test.use({ viewport: { width: 1600, height: 1000 } });
async function openButtons(page: Page, story = "playground") {
await page.goto(`/iframe.html?id=design-buttons--${story}&viewMode=story&globals=theme:dark`);
await expect(page.getByTestId("buttons-playground")).toBeVisible();
return page.getByTestId("proposal-button-samples");
}
test("the playground uses a single sandbox inventory and proposal theme", async ({ page }) => {
const samples = await openButtons(page);
await expect(samples.locator("[data-button-variant]")).toHaveCount(7);
await expect(samples.locator("[data-button-size]")).toHaveCount(8);
await expect(page.locator(".design-proposal-theme")).toBeVisible();
await expect(page.locator("iframe")).toHaveCount(0);
await expect(page.getByRole("region", { name: "Button usage inventory", exact: true })).toHaveCount(0);
await expect(page.getByLabel("Comparison layout", { exact: true })).toHaveCount(0);
await expect(page.getByRole("group", { name: "Button playground controls", exact: true })).toHaveCount(1);
await expect(samples.getByRole("region", { name: "All button variants", exact: true }).locator('[aria-busy="true"]')).toHaveCount(7);
});
test("on-canvas controls update the live sandbox specimen and reset it", async ({ page }) => {
const samples = await openButtons(page);
await page.getByLabel("Button label", { exact: true }).fill("Review changes");
await page.getByLabel("Button variant", { exact: true }).selectOption("cta");
await page.getByLabel("Button size", { exact: true }).selectOption("sm");
await page.getByLabel("Button icon", { exact: true }).selectOption("trailing");
await page.getByLabel("Button state", { exact: true }).selectOption("loading");
const specimen = samples.getByRole("region", { name: "Live specimen", exact: true });
const button = specimen.getByRole("button", { name: "Review changes", exact: true });
await expect(button).toBeDisabled();
await expect(button).toHaveAttribute("aria-busy", "true");
await expect(button).toHaveAttribute("data-variant", "cta");
await expect(button).toHaveAttribute("data-size", "sm");
await page.getByRole("button", { name: "Reset controls", exact: true }).click();
await expect(page.getByLabel("Button label", { exact: true })).toHaveValue("Create task");
await expect(specimen.getByRole("button", { name: "Create task", exact: true })).toBeEnabled();
});
test("sandbox context actions use local state and menus inherit the same theme", async ({ page }) => {
await openButtons(page);
const toolbar = page.getByRole("toolbar", { name: "Task list controls" });
await toolbar.getByRole("button", { name: "Board view", exact: true }).click();
await expect(toolbar.getByRole("button", { name: "Board view", exact: true })).toHaveAttribute("aria-pressed", "true");
await toolbar.getByRole("button", { name: "Task view: All tasks", exact: true }).click();
await page.getByRole("menuitemradio", { name: "Needs attention", exact: true }).click();
await expect(toolbar.getByRole("button", { name: "Task view: Needs attention", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Star preview", exact: true }).click();
await expect(page.getByRole("button", { name: "Unstar preview", exact: true })).toHaveAttribute("aria-pressed", "true");
await toolbar.getByRole("button", { name: "Filter", exact: true }).click();
const filters = page.getByRole("dialog", { name: "Filters", exact: true });
await expect(filters).toHaveCSS("background-color", "rgb(26, 26, 26)");
const pageBackground = await page.getByTestId("buttons-playground").evaluate(element => getComputedStyle(element).backgroundColor);
await expect(filters).toHaveCSS("background-color", pageBackground);
});
test("specimens retain real dimensions, state previews and surface controls", async ({ page }, testInfo) => {
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
const samples = await openButtons(page);
const specimen = samples.getByRole("region", { name: "Live specimen", exact: true });
await expect(page.getByLabel("Button size", { exact: true }).locator('option[value="sm"]')).toHaveText("Small · 36px high");
await page.getByLabel("Button variant", { exact: true }).selectOption("cta");
const button = specimen.getByRole("button", { name: "Create task", exact: true });
await button.hover();
await button.evaluate(async element => { await Promise.all(element.getAnimations().map(animation => animation.finished)); });
const hoveredBackground = await button.evaluate(element => getComputedStyle(element).backgroundColor);
await page.getByLabel("Button state", { exact: true }).selectOption("hover");
await expect(button).toHaveCSS("background-color", hoveredBackground);
await page.getByLabel("Button state", { exact: true }).selectOption("focus-visible");
await expect(button).not.toHaveCSS("box-shadow", "none");
await page.getByLabel("Button size", { exact: true }).selectOption("icon-xs");
await expect(specimen.getByTestId("button-pixel-size")).toHaveText("24 × 24px");
await expect(button).toHaveAttribute("aria-label", "Create task");
await page.getByLabel("Button surface", { exact: true }).selectOption("muted");
await expect(specimen.locator(".bg-muted").first()).toHaveCSS("background-color", "oklch(0.269 0 0)");
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: testInfo.outputPath("sandbox-button-workbench.png") });
expect(errors).toEqual([]);
});
@@ -0,0 +1,107 @@
import { expect, test, type Page } from "@playwright/test";
test.use({ viewport: { width: 1440, height: 1000 } });
async function openColors(page: Page, story: string) {
await page.goto(`/iframe.html?id=design-foundations-colors--${story}&viewMode=story&globals=theme:dark`);
await expect(page.getByTestId("color-foundations-preview")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await page.mouse.move(0, 0);
}
test("color reference reads the Paper palette and foreground focus color from the active CSS theme", async ({ page }, testInfo) => {
await openColors(page, "token-reference");
const expected = {
foreground: "oklch(0.985 0 0)",
background: "#1a1a1a",
"background-alt": "oklch(0.145 0 0)",
muted: "oklch(0.269 0 0)",
"muted-highlight": "#333333",
"muted-alt": "#202020",
"muted-foreground": "oklch(0.708 0 0)",
border: "oklch(1 0 0 / 10%)",
ring: "oklch(0.985 0 0)",
"focus-ring-halo": "color-mix(in srgb, oklch(0.985 0 0) 20%, transparent)",
};
await expect(page.locator("tr[data-token]")).toHaveCount(Object.keys(expected).length);
for (const [name, value] of Object.entries(expected)) {
await expect(page.locator(`tr[data-token="${name}"] [data-token-value]`)).toHaveText(value);
}
await expect(page.getByTestId("color-foundations-preview")).toHaveCSS("background-color", "rgb(26, 26, 26)");
await page.screenshot({ path: testInfo.outputPath("color-token-reference.png"), fullPage: true });
});
test("shared cards use three surfaces and one border; inputs use background-alt", async ({ page }, testInfo) => {
await openColors(page, "surfaces-and-elevation");
for (const [level, color] of [["low", "rgb(26, 26, 26)"], ["middle", "rgb(32, 32, 32)"], ["high", "oklch(0.269 0 0)"]]) {
const card = page.locator(`[data-slot="card"][data-elevation="${level}"]`);
await expect(card).toHaveCSS("background-color", color);
await expect(card).toHaveCSS("border-color", "oklch(1 0 0 / 0.1)");
}
await expect(page.locator('[data-pane="Left sidebar"]')).toHaveCSS("background-color", "oklch(0.269 0 0)");
for (const field of [page.getByLabel("Task name"), page.getByLabel("Search tasks", { exact: true })]) {
await expect(field).toHaveCSS("background-color", "oklch(0.145 0 0)");
await expect(field).toHaveCSS("border-color", "oklch(1 0 0 / 0.1)");
}
await page.getByLabel("Task name").focus();
await expect(page.getByLabel("Task name")).toHaveCSS("border-color", "oklch(0.985 0 0)");
await expect.poll(() => page.getByLabel("Task name").evaluate(element => getComputedStyle(element).boxShadow)).toContain("/ 0.2)");
expect(await page.getByLabel("Task name").evaluate(element => getComputedStyle(element).boxShadow)).toContain("3px");
await page.screenshot({ path: testInfo.outputPath("color-surfaces-and-elevation.png"), fullPage: true });
});
test("task hover and portaled menus share the palette; production stories restore their theme", async ({ page }, testInfo) => {
await openColors(page, "text-and-interaction");
const row = page.locator('[data-slot="task-row"]').first();
const title = row.locator('[data-slot="task-row-title"]');
await expect(title).toHaveCSS("color", "oklch(0.708 0 0)");
await expect(title).toHaveCSS("font-weight", "400");
await row.hover();
await expect(row).toHaveCSS("background-color", "oklch(0.269 0 0)");
await expect(title).toHaveCSS("color", "oklch(0.985 0 0)");
await expect(title).toHaveCSS("font-weight", "400");
await page.getByRole("button", { name: "Columns", exact: true }).click();
const menu = page.getByRole("menu", { name: "Columns", exact: true });
await expect(menu).toHaveCSS("background-color", "rgb(26, 26, 26)");
await expect(menu).toHaveCSS("border-color", "oklch(1 0 0 / 0.1)");
const option = menu.getByRole("menuitemcheckbox", { name: "Task ID", exact: true });
await option.hover();
await expect(option).toHaveCSS("background-color", "rgb(51, 51, 51)");
await expect(option).toHaveCSS("color", "oklch(0.985 0 0)");
await page.screenshot({ path: testInfo.outputPath("color-interaction-and-portal.png"), fullPage: true });
await page.keyboard.press("Escape");
// The CSS scope must disappear with its marker, even within the same document.
await page.locator(".design-proposal-theme").evaluate(element => element.classList.remove("design-proposal-theme"));
await expect.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--background").trim())).toBe("oklch(0.205 0 0)");
await expect.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--muted-alt").trim())).toBe("");
await expect.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--ring").trim())).toBe("oklch(0.556 0 0)");
await page.goto("/iframe.html?id=foundations-primitive-matrix--all-primitives&viewMode=story&globals=theme:dark");
await expect(page.getByRole("heading", { name: "Primitives and interaction states", exact: true })).toBeVisible();
await expect(page.locator(".design-proposal-theme")).toHaveCount(0);
await expect.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--background").trim())).toBe("oklch(0.205 0 0)");
});
test("light proposals inherit the production palette, including portaled menus", async ({ page }) => {
const palette = () => page.evaluate(() => {
const style = getComputedStyle(document.documentElement);
return Object.fromEntries(["background", "foreground", "card", "card-foreground", "popover", "popover-foreground", "primary", "primary-foreground", "secondary", "muted", "muted-foreground", "accent", "border", "input", "ring", "sidebar"].map(name => [name, style.getPropertyValue(`--${name}`).trim()]));
});
await page.goto("/iframe.html?id=foundations-primitive-matrix--all-primitives&viewMode=story&globals=theme:light");
await expect(page.getByRole("heading", { name: "Primitives and interaction states", exact: true })).toBeVisible();
const production = await palette();
await page.goto("/iframe.html?id=design-foundations-colors--token-reference&viewMode=story&globals=theme:light");
await expect(page.getByTestId("color-foundations-preview")).toBeVisible();
await expect(page.locator("html")).not.toHaveClass(/dark/);
expect(await palette()).toEqual(production);
await expect(page.locator('tr[data-token="background"] [data-token-value]')).toHaveText(production.background);
await page.goto("/iframe.html?id=design-foundations-colors--surfaces-and-elevation&viewMode=story&globals=theme:light");
await expect(page.getByLabel("Task name")).toBeVisible();
await expect(page.locator('[data-slot="card"][data-elevation="middle"]')).toHaveCSS("background-color", "oklch(0.97 0 0)");
await page.getByLabel("Task name").focus();
await expect(page.getByLabel("Task name")).toHaveCSS("outline-style", "solid");
await page.goto("/iframe.html?id=design-foundations-colors--text-and-interaction&viewMode=story&globals=theme:light");
await page.getByRole("button", { name: "Columns", exact: true }).click();
await expect(page.getByRole("menu", { name: "Columns", exact: true })).toHaveCSS("background-color", "oklch(1 0 0)");
expect(await palette()).toEqual(production);
});
@@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test";
test.use({ viewport: { width: 1512, height: 1050 } });
test("standalone Popover sections support toggles, radio checks and the canonical account menu", async ({ page }, testInfo) => {
await page.goto("/iframe.html?id=design-flyout--columns&viewMode=story&globals=theme:dark");
const columns = page.getByRole("dialog", { name: "Columns", exact: true });
await expect(columns).toBeVisible();
const taskId = columns.getByRole("button", { name: "Task ID", exact: true });
await page.mouse.move(0, 0);
await expect(taskId).toHaveCSS("color", "oklch(0.708 0 0)");
await expect(taskId.locator("svg.lucide-check")).toHaveCSS("color", "oklch(0.708 0 0)");
await taskId.hover();
await expect(taskId).toHaveCSS("color", "oklch(0.985 0 0)");
await expect(taskId.locator("svg.lucide-check")).toHaveCSS("color", "oklch(0.985 0 0)");
await taskId.click();
await expect(taskId).toHaveAttribute("aria-pressed", "false");
await expect(taskId.locator("svg")).toHaveCount(0);
await columns.getByRole("button", { name: "Reset defaults", exact: true }).click();
await expect(taskId).toHaveAttribute("aria-pressed", "true");
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath("flyout-component.png") });
await columns.screenshot({ path: testInfo.outputPath("flyout-component-panel.png") });
await page.keyboard.press("Escape");
await expect(columns).toBeHidden();
await expect(page.getByRole("button", { name: "Columns", exact: true })).toBeFocused();
await page.goto("/iframe.html?id=design-flyout--views&viewMode=story&globals=theme:dark");
await expect(page.getByRole("menuitemradio")).toHaveText(["Inbox", "Unread", "Needs attention", "All tasks"]);
await expect(page.getByRole("menuitemradio", { name: "All tasks", exact: true })).toBeChecked();
await page.getByRole("menuitemradio", { name: "Unread", exact: true }).click();
await page.getByRole("button", { name: "Views", exact: true }).click();
const unread = page.getByRole("menuitemradio", { name: "Unread", exact: true });
await expect(unread).toBeChecked();
await expect(unread.locator("svg.lucide-check")).toBeVisible();
await page.goto("/iframe.html?id=design-flyout--profile&viewMode=story&globals=theme:dark");
await expect(page.getByRole("link", { name: "View profile", exact: true })).toBeVisible();
await expect(page.getByRole("dialog", { name: "Account", exact: true }).locator('[data-slot="avatar-fallback"]')).toHaveText("R");
const invite = page.getByRole("link", { name: "Invite", exact: true });
await expect(invite).toHaveAttribute("href", /\/company\/settings\/members\?tab=invites$/);
await invite.click();
await expect(page.getByRole("dialog", { name: "Account", exact: true })).toBeHidden();
});
test("Popover Account Menu matches the canonical Sidebar Account Menu", async ({ page }, testInfo) => {
const snapshots = [];
for (const story of ["design-sidebar--account-menu", "design-flyout--profile"]) {
await page.goto(`/iframe.html?id=${story}&viewMode=story&globals=theme:dark`);
const menu = page.getByRole("dialog", { name: "Account", exact: true });
await expect(menu.getByRole("link", { name: "Invite", exact: true })).toBeVisible();
await page.mouse.move(0, 0);
snapshots.push(await menu.evaluate(element => ({
actions: [...element.querySelectorAll("a,button")].map(action => ({ label: action.textContent?.trim(), href: action.getAttribute("href"), target: action.getAttribute("target") })),
width: element.getBoundingClientRect().width,
height: element.getBoundingClientRect().height,
shadow: getComputedStyle(element).boxShadow,
background: getComputedStyle(element).backgroundColor,
})));
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
const trigger = page.getByRole("button", { name: "Open account menu", exact: true });
await expect(trigger).toBeFocused();
await trigger.click();
await expect(menu).toBeVisible();
if (story === "design-flyout--profile") await menu.screenshot({ path: testInfo.outputPath("canonical-account-menu.png") });
await menu.getByRole("link", { name: "Settings", exact: true }).click();
await expect(menu).toBeHidden();
}
expect(snapshots[1]).toEqual(snapshots[0]);
});
test("Shadow control previews an override and clearing it restores the shared token", async ({ page }, testInfo) => {
await page.goto("/?path=/story/design-flyout--columns");
const preview = page.frameLocator("#storybook-preview-iframe");
const panel = preview.getByRole("dialog", { name: "Columns", exact: true });
await expect(panel).toBeVisible();
const defaultShadow = await panel.evaluate(element => getComputedStyle(element).boxShadow);
const showAddons = page.getByRole("button", { name: "Show addon panel", exact: true });
if (await showAddons.isVisible()) await showAddons.click();
await page.getByRole("tab", { name: /^Controls/ }).click();
const shadow = page.locator("#control-shadow");
await shadow.fill("0 8px 24px rgb(0 0 0 / 35%)");
await expect(panel).toHaveCSS("box-shadow", "rgba(0, 0, 0, 0.35) 0px 8px 24px 0px");
await shadow.fill("");
await expect(panel).toHaveCSS("box-shadow", defaultShadow);
await page.screenshot({ path: testInfo.outputPath("flyout-shadow-controls.png") });
});
+3 -1
View File
@@ -16,7 +16,9 @@ const snapshotDir = process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR
export default defineConfig({
testDir: ".",
projects: [
{ name: "stories", testIgnore: "agent-personas.spec.ts" },
// The reproduction trial has a dedicated fixed-clock/browser contract and
// requires its frozen first-submission PNG via TRIAL_REFERENCE_PNG.
{ name: "stories", testIgnore: ["agent-personas.spec.ts", "task-list-reproduction-trial.spec.ts"] },
{
name: "agent-personas", testMatch: "agent-personas.spec.ts", fullyParallel: false,
snapshotPathTemplate: `${snapshotDir}/agent-personas/{arg}{ext}`,
@@ -0,0 +1,122 @@
import { expect, test } from "@playwright/test";
test.use({ viewport: { width: 1100, height: 862 } });
async function openSidebar(page: import("@playwright/test").Page, story = "default") {
await page.goto(`/iframe.html?id=design-sidebar--${story}&viewMode=story&globals=theme:dark`);
await expect(page.locator(".design-sidebar")).toBeVisible();
}
test("standalone sidebar supports resize, navigation states, search and production menus", async ({ page }, testInfo) => {
await openSidebar(page);
const sidebar = page.locator(".design-sidebar");
await expect(sidebar).toHaveCSS("background-color", "oklch(0.269 0 0)");
await expect(sidebar).toHaveCSS("background-clip", "padding-box");
const headerBorder = await page.locator(".design-page-header").evaluate(element => getComputedStyle(element).borderBottomColor);
await expect(sidebar).toHaveCSS("border-right-color", headerBorder);
await expect(sidebar.getByRole("link", { name: "Tasks", exact: true })).toHaveAttribute("aria-current", "page");
const dashboard = sidebar.getByRole("link", { name: "Dashboard", exact: true });
await dashboard.hover();
await expect(dashboard).toHaveCSS("background-color", "rgb(51, 51, 51)");
await dashboard.focus();
await expect(dashboard).toBeFocused();
const resize = page.getByRole("separator", { name: "Resize sidebar" });
await resize.press("ArrowRight");
await expect(resize).toHaveAttribute("aria-valuenow", "256");
await page.reload();
await expect(resize).toHaveAttribute("aria-valuenow", "256");
await resize.dblclick();
await expect(resize).toHaveAttribute("aria-valuenow", "240");
await expect(sidebar).toHaveCSS("width", "240px");
await page.reload();
await expect(resize).toHaveAttribute("aria-valuenow", "240");
await sidebar.getByRole("button", { name: "Global search" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await page.screenshot({ path: testInfo.outputPath("sidebar-component.png") });
await openSidebar(page, "organization-menu");
const research = page.getByRole("menuitem", { name: /Research Bureau/ });
await expect(research).toBeVisible();
await research.click();
await expect(sidebar.locator(".design-sidebar-recents a")).toHaveCount(0);
await openSidebar(page, "account-menu");
await expect(page.getByRole("link", { name: "Invite", exact: true })).toBeVisible();
});
test("sidebar specimens cover narrow, overflowing and empty recents", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await openSidebar(page, "narrow");
await expect(page.getByRole("separator", { name: "Resize sidebar" })).toHaveAttribute("aria-valuenow", "208");
await openSidebar(page, "long-labels");
const label = page.locator(".design-sidebar-recents a").first().locator("[data-cell-marquee]");
await expect(label).toHaveAttribute("data-truncated", "true");
await label.hover();
await expect.poll(() => label.locator(".cell-marquee-content").evaluate(element => getComputedStyle(element).transform)).not.toBe("matrix(1, 0, 0, 1, 0, 0)");
await openSidebar(page, "many-recent-tasks");
await expect(page.locator(".design-sidebar-recents a")).toHaveCount(30);
const recents = page.getByRole("region", { name: "Recent tasks", exact: true });
expect(await recents.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
const navigation = page.locator(".design-sidebar-navigation");
const header = page.getByText("Recent Tasks", { exact: true });
const headerY = (await header.boundingBox())!.y;
const navScroll = await navigation.evaluate(element => element.scrollTop);
await expect(recents).toHaveCSS("scrollbar-color", "rgba(0, 0, 0, 0) rgba(0, 0, 0, 0)");
await recents.hover();
await expect(recents).not.toHaveAttribute("data-scroll-active");
await page.mouse.wheel(0, 300);
await expect(recents).toHaveAttribute("data-scroll-active", "true");
await expect(recents).toHaveCSS("scrollbar-color", "oklch(0.4 0 0) rgba(0, 0, 0, 0)");
expect(await recents.evaluate(element => element.scrollTop)).toBeGreaterThan(0);
expect(await navigation.evaluate(element => element.scrollTop)).toBe(navScroll);
expect((await header.boundingBox())!.y).toBe(headerY);
await expect(recents).not.toHaveAttribute("data-scroll-active");
await expect(recents).toHaveCSS("scrollbar-color", "rgba(0, 0, 0, 0) rgba(0, 0, 0, 0)");
await expect(page.getByRole("button", { name: "Open account menu", exact: true })).toBeInViewport();
await openSidebar(page, "empty-recent-tasks");
await expect(page.locator(".design-sidebar-recents")).toHaveCount(0);
await expect(page.getByRole("link", { name: "Tasks", exact: true })).toBeVisible();
});
test("organization title resets after menu dismissal outside its hit area", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await openSidebar(page);
const trigger = page.getByRole("button", { name: /organization switcher/ });
const label = trigger.locator("[data-cell-marquee]");
for (const dismissal of ["escape", "outside"]) {
await trigger.hover();
await expect(label).toHaveAttribute("data-revealing", "true");
await expect.poll(() => label.locator(".cell-marquee-content").evaluate(element => getComputedStyle(element).transform)).not.toBe("matrix(1, 0, 0, 1, 0, 0)");
await trigger.click();
await expect(page.getByRole("menuitem", { name: /Research Bureau/ })).toBeVisible();
await page.mouse.move(800, 200);
if (dismissal === "escape") await page.keyboard.press("Escape");
else await page.mouse.click(800, 200);
await expect(page.getByRole("menuitem", { name: /Research Bureau/ })).toBeHidden();
await expect(trigger).toBeFocused();
await expect(label).not.toHaveAttribute("data-revealing");
await expect(label.locator(".cell-marquee-content")).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)");
}
// Restored focus remains usable; tabbing away and back is a new interaction.
await trigger.press("Tab");
await page.keyboard.press("Shift+Tab");
await expect(trigger).toBeFocused();
await expect(label).toHaveAttribute("data-revealing", "true");
});
test("light sidebar uses production surface, navigation contrast and selected colors", async ({ page }) => {
await page.goto('/iframe.html?id=design-sidebar--default&viewMode=story&globals=theme:light');
const sidebar = page.locator('.design-sidebar');
await expect(sidebar).toBeVisible();
// The production primary sidebar renders a border/background blend, not --sidebar.
await expect(sidebar).toHaveCSS('background-color', 'color(srgb 0.948957 0.949131 0.949147)');
const tasks = sidebar.getByRole('link', { name: 'Tasks', exact: true });
await expect(tasks).toHaveCSS('background-color', 'oklch(0.88 0 0)');
await expect(tasks).toHaveCSS('color', 'oklch(0.205 0 0)');
const projects = sidebar.getByRole('link', { name: 'Projects', exact: true });
await expect(projects).toHaveCSS('color', 'oklab(0.145 0 0 / 0.8)');
await projects.hover();
await expect(projects).toHaveCSS('background-color', 'oklch(0.88 0 0)');
await expect(projects).toHaveCSS('color', 'oklch(0.205 0 0)');
});
@@ -0,0 +1,44 @@
import { expect, test } from "@playwright/test";
test.use({ viewport: { width: 1440, height: 1100 } });
test("canonical tabs share filled selection with compact and standard type sizes", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.goto("/iframe.html?id=design-tabs--playground&viewMode=story&globals=theme:dark");
const preview = page.getByTestId("tabs-preview");
await expect(preview.getByRole("region")).toHaveCount(2);
for (const [name, size, height] of [["Standard", "14px", 32], ["Compact", "12px", 24]] as const) {
const section = preview.getByRole("region", { name, exact: true });
const paperclip = section.getByRole("tab", { name: "Paperclip", exact: true });
await paperclip.focus();
await page.keyboard.press("ArrowRight");
const selected = section.getByRole("tab", { name: "Composio", exact: true });
await expect(selected).toBeFocused();
await expect(selected).toHaveAttribute("aria-selected", "true");
await expect(section.getByRole("tabpanel", { name: "Composio", exact: true })).toBeVisible();
await expect(selected).toHaveCSS("font-size", size);
await expect(selected).toHaveCSS("font-weight", "400");
expect(await selected.evaluate(element => element.getBoundingClientRect().height)).toBe(height);
expect(await selected.evaluate(element => getComputedStyle(element).backgroundColor)).not.toBe("rgba(0, 0, 0, 0)");
expect(await selected.evaluate(element => getComputedStyle(element, "::after").display)).toBe("none");
}
const compact = preview.getByRole("region", { name: "Compact", exact: true });
await compact.getByRole("radio", { name: "Videos", exact: true }).click();
await expect(compact.getByRole("radio", { name: "Videos", exact: true })).toHaveAttribute("aria-checked", "true");
await compact.getByRole("radio", { name: "Videos", exact: true }).click();
await expect(compact.getByRole("radio", { name: "Videos", exact: true })).toHaveAttribute("aria-checked", "true");
await expect(compact.getByText("Artifact type: video", { exact: true })).toBeVisible();
await preview.getByRole("combobox", { name: "Tabs container width", exact: true }).selectOption("narrow");
expect(await preview.getByTestId("tabs-specimens").evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
expect(errors).toEqual([]);
});
test("disabled choices are unavailable in canonical tabs and segmented controls", async ({ page }) => {
await page.goto("/iframe.html?id=design-tabs--playground&viewMode=story&globals=theme:dark");
await expect(page.getByRole("radio", { name: "Restricted", exact: true })).toBeDisabled();
await page.goto("/iframe.html?id=design-segmented-controllers--disabled&viewMode=story&globals=theme:dark");
await expect(page.getByRole("tab", { name: "Agent Actions", exact: true })).toBeDisabled();
await page.getByRole("tab", { name: "Activity", exact: true }).focus();
await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: "Activity", exact: true })).toBeFocused();
});
@@ -0,0 +1,72 @@
import { expect, test } from "@playwright/test";
test.use({ viewport: { width: 1100, height: 900 } });
async function openPreview(page: import("@playwright/test").Page, story = "playground") {
await page.goto(`/iframe.html?id=design-task-hover-card--${story}&viewMode=story&globals=theme:dark`);
await expect(page.getByTestId("task-hover-card-reference")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
}
test("task hover card follows the padded Paper layout and shared popover shadow", async ({ page }, testInfo) => {
await openPreview(page);
const card = page.getByTestId("task-hover-card-reference");
await expect(card).toHaveCSS("width", "280px");
await expect(card).toHaveCSS("padding", "16px");
await expect(card).toHaveCSS("background-color", "rgb(26, 26, 26)");
await expect(card).toHaveCSS("border-top-width", "1px");
const shadow = await card.evaluate(element => getComputedStyle(element).getPropertyValue("--shadow-flyout").trim());
expect(shadow).not.toBe("");
await expect(card).toHaveCSS("box-shadow", "rgba(0, 0, 0, 0.2) 0px 4px 16px 0px");
const title = card.locator('[data-slot="task-preview-title"]');
await expect(title).toHaveCSS("font-size", "14px");
await expect(title).toHaveCSS("font-weight", "600");
await expect(title).toHaveCSS("line-height", "18px");
await expect(title).toHaveCSS("-webkit-line-clamp", "2");
await expect(title).toHaveCSS("color", "oklch(0.985 0 0)");
await expect(card.locator('[data-slot="task-preview-id"]')).toHaveCSS("font-size", "12px");
const description = card.locator('[data-slot="task-preview-description"]');
await expect(description).toHaveCSS("font-size", "12px");
await expect(description).toHaveCSS("line-height", "18px");
await expect(description).toHaveCSS("-webkit-line-clamp", "3");
await expect(card.getByTestId("quicklook-project")).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("task-hover-card.png"), fullPage: true });
});
test("text links can disable previews while activity keeps them and summary rows stay opted out", async ({ page }) => {
await openPreview(page);
const popup = page.locator('[data-slot="popover-content"][data-quicklook]');
const link = page.getByRole("link", { name: "Preview linked task PAP-173", exact: true });
await link.hover();
await expect(popup).toBeVisible();
await expect(popup).toHaveCSS("padding", "16px");
await expect(popup).toHaveCSS("width", "280px");
await expect(popup.locator('[data-slot="task-preview-title"]')).toHaveText("Artifact Review: Finish the remaining approved wireframe gaps");
await page.mouse.move(1000, 850);
await expect(popup).toBeHidden();
await link.focus();
await expect(popup).toBeVisible();
await page.keyboard.press("Escape");
await expect(popup).toBeHidden();
await page.getByRole("button", { name: "Hover preview: On", exact: true }).click();
await link.hover();
await expect(page.getByRole("button", { name: "Hover preview: Off", exact: true })).toBeVisible();
// Give the production hover-intent timer a chance to fire, then verify no card.
await page.waitForTimeout(200);
await expect(popup).toHaveCount(0);
const activity = page.getByRole("region", { name: "Activity preview example", exact: true });
await activity.getByRole("link").hover();
await expect(popup).toBeVisible();
const summary = page.getByRole("region", { name: "Summary without hover preview", exact: true });
await summary.getByRole("link", { name: /^Open PAP-173:/ }).hover();
await expect(popup).toBeHidden();
await expect(summary.locator('[data-slot="task-row"]')).toHaveCSS("height", "44px");
});
test("missing descriptions collapse naturally without leaving an empty block", async ({ page }) => {
await openPreview(page, "no-description");
const card = page.getByTestId("task-hover-card-reference");
await expect(card.locator('[data-slot="task-preview-description"]')).toHaveCount(0);
await expect(card.locator('[data-slot="task-preview-title"]')).toBeVisible();
await expect(card).toHaveCSS("padding", "16px");
});
@@ -0,0 +1,478 @@
import { expect, test, type Page } from "@playwright/test";
test.use({ viewport: { width: 1512, height: 862 } });
async function openPage(page: Page, story = "default") {
await page.goto(`/iframe.html?id=tasks-list-page-design--${story}&viewMode=story&globals=theme:dark`);
await expect(page.getByTestId("design-page-shell")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await page.mouse.move(0, 0);
}
const rows = (page: Page) => page.locator('.design-task-list-panel [data-slot="task-row"]');
test("toolbar outlines remain opaque and consistent across control fills without changing popover borders", async ({ page }, testInfo) => {
await openPage(page);
const controls = [
page.locator(".design-task-search"),
page.locator(".design-view-trigger"),
page.locator(".design-view-toggle"),
page.getByRole("button", { name: "Collapse all tasks", exact: true }),
page.locator(".design-toolbar-filter > button"),
];
const border = await controls[0].evaluate(element => getComputedStyle(element).borderTopColor);
expect(border).not.toContain("/"); // An opaque color has no fractional alpha.
expect(border).not.toBe("transparent");
const fill = await controls[0].evaluate(element => getComputedStyle(element).backgroundColor);
expect(await controls[1].evaluate(element => getComputedStyle(element).backgroundColor)).not.toBe(fill);
for (const control of controls) {
await expect(control).toHaveCSS("border-top-color", border);
await expect(control).toHaveCSS("border-top-width", "1px");
}
await controls[1].hover();
await expect(controls[1]).toHaveCSS("border-top-color", border);
await page.getByRole("button", { name: "Board view", exact: true }).click();
await expect(page.getByRole("button", { name: "Board view", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(controls[2]).toHaveCSS("border-top-color", border);
await page.getByRole("textbox", { name: "Search tasks", exact: true }).focus();
await expect(controls[0]).toHaveCSS("border-top-color", "oklch(0.985 0 0)");
await expect(controls[0]).toHaveCSS("outline-style", "none");
await expect.poll(() => controls[0].evaluate(element => getComputedStyle(element).boxShadow)).toContain("/ 0.2)");
expect(await controls[0].evaluate(element => getComputedStyle(element).boxShadow)).toContain("3px");
await controls[4].click();
const popover = page.getByRole("dialog", { name: "Filters", exact: true });
await expect(popover).toBeVisible();
const popoverBorder = await popover.evaluate(element => getComputedStyle(element).borderTopColor);
expect(popoverBorder).toContain("/ 0.1"); // Surface borders keep the translucent foundation token.
await page.keyboard.press("Escape");
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath("toolbar-consistent-borders.png"), fullPage: true });
});
test("tabbing through the toolbar shows the shared foreground edge and halo on every control", async ({ page }, testInfo) => {
await openPage(page);
const toolbar = page.getByRole("toolbar", { name: "Task list controls", exact: true });
const search = toolbar.getByRole("textbox", { name: "Search tasks", exact: true });
await search.focus();
for (const control of [
toolbar.getByRole("button", { name: "List view", exact: true }),
toolbar.getByRole("button", { name: "Board view", exact: true }),
toolbar.getByRole("button", { name: "Collapse all tasks", exact: true }),
toolbar.locator(".design-toolbar-filter > button"),
toolbar.getByRole("button", { name: "New Task", exact: true }),
]) {
await page.keyboard.press("Tab");
await expect(control).toBeFocused();
await expect(control).toHaveCSS("outline-style", "solid");
await expect(control).toHaveCSS("outline-color", "oklch(0.985 0 0)");
await expect.poll(() => control.evaluate(element => getComputedStyle(element).boxShadow)).toContain("/ 0.2)");
expect(await control.evaluate(element => getComputedStyle(element).boxShadow)).toContain("3px");
}
// The view menu precedes search and uses a different trigger wrapper.
await search.focus();
await page.keyboard.press("Shift+Tab");
const view = toolbar.getByRole("button", { name: "Task view: All tasks", exact: true });
await expect(view).toBeFocused();
await expect(view).toHaveCSS("outline-color", "oklch(0.985 0 0)");
await expect.poll(() => view.evaluate(element => getComputedStyle(element).boxShadow)).toContain("/ 0.2)");
await page.screenshot({ path: testInfo.outputPath("toolbar-keyboard-focus.png"), fullPage: true });
});
test("full page uses the reviewed row contract and three default columns", async ({ page }, testInfo) => {
await openPage(page);
await expect(page.getByRole("navigation", { name: "Main navigation" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Tasks", exact: true })).toBeVisible();
await expect(page.getByRole("toolbar", { name: "Task list controls" })).toBeVisible();
await expect(rows(page)).toHaveCount(10);
await expect(page.getByRole("columnheader")).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(page.locator('.task-list-sort-button[data-active="true"]')).toHaveCount(0);
await expect(page.getByRole("button", { name: "Group tasks", exact: true })).toHaveCount(0);
for (const link of await page.locator(".design-sidebar-navigation a").all()) await expect(link).toHaveCSS("font-size", "14px");
await expect(page.locator(".design-sidebar-new-task")).toHaveCSS("font-size", "14px");
await expect(page.getByRole("button", { name: "Open account menu", exact: true })).toHaveCSS("font-size", "14px");
await expect(page.getByRole("button", { name: "Open account menu", exact: true }).locator('[data-slot="avatar-fallback"]')).toHaveText("R");
const recentLinks = page.locator(".design-sidebar-recents a");
await expect(recentLinks).toHaveCount(5);
await expect(page.locator(".design-sidebar-recents").getByText("Recent Tasks", { exact: true })).toBeVisible();
for (const link of await recentLinks.all()) await expect(link).toHaveCSS("font-weight", "400");
const dashboard = page.getByRole("navigation", { name: "Main navigation" }).getByRole("link", { name: "Dashboard", exact: true });
await expect(dashboard).toHaveCSS("line-height", "21px");
expect((await dashboard.locator("svg").boundingBox())!.x).toBe(28);
expect((await dashboard.boundingBox())!.height).toBe(33);
expect((await recentLinks.first().boundingBox())!.y).toBeGreaterThan((await page.getByRole("link", { name: "Audit", exact: true }).boundingBox())!.y);
await expect(page.locator(".design-sidebar-account")).toHaveCSS("border-top-width", "0px");
await expect(page.locator(".design-sidebar")).toHaveCSS("background-color", "oklch(0.269 0 0)");
await expect(page.locator(".design-sidebar")).toHaveCSS("border-right-color", "oklch(1 0 0 / 0.1)");
await expect(page.locator(".design-task-search")).toHaveCSS("background-color", "oklch(0.145 0 0)");
const geometry = await page.getByTestId("design-page-shell").evaluate(shell => {
const main = shell.querySelector<HTMLElement>(".design-page-main")!;
const toolbar = shell.querySelector<HTMLElement>(".design-page-toolbar")!;
const row = shell.querySelector<HTMLElement>('[data-slot="task-row"]')!;
const title = row.querySelector<HTMLElement>('[data-slot="task-row-title"]')!;
return {
mainX: main.getBoundingClientRect().x,
headerHeight: shell.querySelector(".design-page-header")!.getBoundingClientRect().height,
toolbarHeight: toolbar.getBoundingClientRect().height,
background: getComputedStyle(main).backgroundColor,
rowHeight: row.getBoundingClientRect().height,
titleSize: getComputedStyle(title).fontSize,
titleLeading: getComputedStyle(title).lineHeight,
pageOverflow: document.documentElement.scrollWidth > window.innerWidth,
};
});
expect(geometry).toEqual({ mainX: 240, headerHeight: 60, toolbarHeight: 44, background: "rgb(26, 26, 26)", rowHeight: 44, titleSize: "14px", titleLeading: "16px", pageOverflow: false });
await page.screenshot({ path: testInfo.outputPath("task-list-page.png"), fullPage: true });
await page.locator(".design-sidebar").screenshot({ path: testInfo.outputPath("task-list-sidebar-paper.png") });
const resize = page.getByRole("separator", { name: "Resize sidebar", exact: true });
await resize.press("ArrowRight");
await expect(resize).toHaveAttribute("aria-valuenow", "256");
await page.reload();
await expect(resize).toHaveAttribute("aria-valuenow", "256");
expect((await page.locator(".design-page-main").boundingBox())!.x).toBe(256);
await resize.hover();
await expect(resize).toHaveCSS("cursor", "col-resize");
await expect(resize.locator(":scope > div")).toHaveCSS("background-color", "oklch(0.556 0 0)");
await page.screenshot({ path: testInfo.outputPath("task-list-sidebar-resize.png"), fullPage: true });
});
test("search preserves ancestor context, recovers from no results, and expansion works", async ({ page }) => {
await openPage(page);
await page.getByRole("button", { name: "Collapse all tasks" }).click();
await expect(rows(page)).toHaveCount(6);
await page.getByRole("button", { name: "Expand all tasks" }).click();
await expect(rows(page)).toHaveCount(10);
await page.getByRole("textbox", { name: "Search tasks", exact: true }).fill("expired credential");
await expect(rows(page)).toHaveCount(3);
await expect(page.locator(".design-task-results")).toHaveText("1 matching task · Parent tasks included for context");
await expect(rows(page).locator('[data-slot="task-row-title"]')).toHaveText(["Improve connector reliability", "Design reconnect and recovery flows", /Document connector recovery behavior/]);
await page.getByRole("textbox", { name: "Search tasks", exact: true }).fill("no matching task exists");
await expect(rows(page)).toHaveCount(0);
await expect(page.getByText("No tasks match this view", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear filters", exact: true }).click();
await expect(rows(page)).toHaveCount(10);
await expect(page.getByRole("textbox", { name: "Search tasks", exact: true })).toHaveValue("");
});
test("views change the collection while keeping task families together", async ({ page }) => {
await openPage(page);
await page.getByRole("button", { name: "Task view: All tasks" }).click();
await expect(page.getByRole("menuitemradio")).toHaveText(["Inbox", "Unread", "Needs attention", "All tasks"]);
await page.getByRole("menuitemradio", { name: "Unread", exact: true }).click();
await expect(rows(page)).toHaveCount(3);
await expect(page.locator(".design-task-results")).toHaveText("3 matching tasks");
await page.reload();
await expect(page.getByRole("button", { name: "Task view: Unread" })).toBeVisible();
await expect(rows(page)).toHaveCount(3);
await page.getByRole("button", { name: "Task view: Unread" }).click();
await page.getByRole("menuitemradio", { name: "Needs attention", exact: true }).click();
await expect(page.locator(".design-task-results")).toHaveText("2 matching tasks · Parent tasks included for context");
await expect(rows(page)).toHaveCount(3);
await expect(rows(page).locator('[data-slot="task-row-title"]')).toHaveText(["Improve connector reliability", "Define connector health states", "Review budget notifications"]);
await page.getByRole("button", { name: "Task view: Needs attention" }).click();
await page.getByRole("menuitemradio", { name: "Inbox", exact: true }).click();
await expect(page.locator(".design-task-results")).toHaveText("6 matching tasks");
await expect(rows(page)).toHaveCount(6);
await page.reload();
await page.getByRole("button", { name: "Task view: Inbox" }).click();
await expect(page.getByRole("menuitemradio", { name: "Inbox", exact: true })).toBeChecked();
await page.getByRole("menuitemradio", { name: "All tasks", exact: true }).click();
await expect(rows(page)).toHaveCount(10);
await expect(page.getByRole("rowgroup", { name: "Improve connector reliability task group", exact: true }).locator('[data-slot="task-row"]')).toHaveCount(5);
await page.reload();
await expect(page.getByRole("button", { name: "Task view: All tasks" })).toBeVisible();
await expect(rows(page)).toHaveCount(10);
});
test("production filters operate on fixtures and list preferences survive board switching", async ({ page }) => {
await openPage(page);
await page.locator(".design-toolbar-filter > button").click();
await page.getByRole("checkbox", { name: "Done", exact: true }).click();
await page.keyboard.press("Escape");
await expect(page.locator(".design-task-results")).toHaveText("2 matching tasks · Parent tasks included for context");
await expect(rows(page)).toHaveCount(3);
await page.getByRole("button", { name: "Board view", exact: true }).click();
await expect(page.locator(".design-task-board")).toBeVisible();
await expect(page.locator(".design-task-list-panel")).toBeHidden();
await expect(page.getByRole("button", { name: "Collapse all tasks", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "List view", exact: true }).click();
await expect(rows(page)).toHaveCount(3);
await expect(page.getByRole("columnheader")).toHaveText(["Task", "Task ID", "Last updated"]);
});
test("refreshed filters use five searchable peer groups and the shared popover shadow", async ({ page }, testInfo) => {
await openPage(page);
await page.getByRole("button", { name: "Columns", exact: true }).click();
const shadow = await page.getByRole("menu", { name: "Columns", exact: true }).evaluate(element => getComputedStyle(element).boxShadow);
await page.keyboard.press("Escape");
await page.locator(".design-toolbar-filter > button").click();
const filters = page.getByRole("dialog", { name: "Filters", exact: true });
await expect(filters).toHaveCSS("box-shadow", shadow);
await expect(filters).toHaveCSS("background-color", "rgb(26, 26, 26)");
await expect(filters.getByRole("heading", { level: 3 })).toHaveText(["Status", "Project", "Creator", "Assignee", "Label"]);
const geometry = await filters.locator(".task-filter-group").evaluateAll(groups => groups.map(group => {
const bounds = group.getBoundingClientRect();
return { x: bounds.x, y: bounds.y, width: bounds.width };
}));
expect(new Set(geometry.map(group => group.y)).size).toBe(1);
for (const [index, group] of geometry.entries()) {
expect(group.width).toBe(200);
if (index) expect(group.x - geometry[index - 1].x).toBe(216);
}
for (const name of ["projects", "creators", "assignees", "labels"]) await expect(filters.getByRole("textbox", { name: `Search ${name}`, exact: true })).toBeVisible();
await expect(filters.getByText("Quick filters", { exact: true })).toHaveCount(0);
for (const dot of await filters.locator(".task-filter-label-dot").all()) await expect(dot).toHaveCSS("border-radius", "5px");
await page.screenshot({ path: testInfo.outputPath("task-filters-five-groups.png") });
await filters.screenshot({ path: testInfo.outputPath("task-filters-panel.png") });
const projectSearch = filters.getByRole("textbox", { name: "Search projects", exact: true });
await projectSearch.fill("no such project");
await expect(filters.getByText("No projects match.", { exact: true })).toBeVisible();
await projectSearch.fill("Board");
await filters.getByRole("checkbox", { name: "Board UI", exact: true }).click();
await filters.getByRole("textbox", { name: "Search creators", exact: true }).fill("Scott");
await filters.getByRole("checkbox", { name: "Scott", exact: true }).click();
await filters.getByRole("textbox", { name: "Search assignees", exact: true }).fill("Codex");
await filters.getByRole("checkbox", { name: "CodexCoder", exact: true }).click();
await filters.getByRole("textbox", { name: "Search labels", exact: true }).fill("Design");
await filters.getByRole("checkbox", { name: "Design system", exact: true }).click();
await expect(rows(page).locator('[data-slot="task-row-title"]')).toHaveText(["Review budget notifications"]);
await filters.getByRole("button", { name: "Clear", exact: true }).click();
await expect(rows(page)).toHaveCount(10);
for (const input of await filters.getByRole("textbox").all()) await expect(input).toHaveValue("");
await expect(filters.getByRole("checkbox", { checked: true })).toHaveCount(0);
await page.setViewportSize({ width: 900, height: 862 });
const bounds = (await filters.boundingBox())!;
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(900);
expect(await filters.locator(".task-filter-group").evaluateAll(groups => new Set(groups.map(group => group.getBoundingClientRect().y)).size)).toBeGreaterThan(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false);
await page.keyboard.press("Escape");
await expect(filters).toBeHidden();
await expect(page.locator(".design-toolbar-filter > button")).toBeFocused();
});
test("New Task opens the shared task composer", async ({ page }) => {
await openPage(page);
await page.getByRole("toolbar", { name: "Task list controls" }).getByRole("button", { name: "New Task", exact: true }).click();
await expect(page.getByRole("dialog", { name: "New task", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Create task", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "New task", exact: true })).toBeHidden();
});
test("board has one horizontal scroll owner and fades only its clipped edges", async ({ page }, testInfo) => {
await openPage(page);
await page.getByRole("button", { name: "Board view", exact: true }).click();
const board = page.locator(".design-task-board");
const viewport = board.locator(".horizontal-scroll-viewport");
const scroll = board.getByRole("region", { name: "Task board columns", exact: true });
const scrollOwners = () => board.evaluate(element => [element, ...element.querySelectorAll("*"), element.closest("main")!].filter(node => {
const style = getComputedStyle(node);
return ["auto", "scroll"].includes(style.overflowX) && node.scrollWidth > node.clientWidth + 1;
}).length);
expect(await scrollOwners()).toBe(1);
await expect(viewport).toHaveAttribute("data-overflow-left", "false");
await expect(viewport).toHaveAttribute("data-overflow-right", "true");
expect(await viewport.evaluate(element => getComputedStyle(element, "::before").opacity)).toBe("0");
expect(await viewport.evaluate(element => getComputedStyle(element, "::after").opacity)).toBe("1");
expect(await viewport.evaluate(element => getComputedStyle(element, "::after").pointerEvents)).toBe("none");
await scroll.evaluate(element => element.scrollTo({ left: element.scrollWidth / 4 }));
await expect(viewport).toHaveAttribute("data-overflow-left", "true");
await expect(viewport).toHaveAttribute("data-overflow-right", "true");
await page.screenshot({ path: testInfo.outputPath("task-board-scroll-fades.png") });
await scroll.evaluate(element => element.scrollTo({ left: element.scrollWidth }));
await expect(viewport).toHaveAttribute("data-overflow-left", "true");
await expect(viewport).toHaveAttribute("data-overflow-right", "false");
await scroll.hover();
await page.mouse.wheel(-300, 0);
await expect(viewport).toHaveAttribute("data-overflow-right", "true");
await page.setViewportSize({ width: 2400, height: 862 });
await expect(viewport).toHaveAttribute("data-overflow-left", "false");
await expect(viewport).toHaveAttribute("data-overflow-right", "false");
expect(await scrollOwners()).toBe(0);
await page.setViewportSize({ width: 1000, height: 862 });
await expect(viewport).toHaveAttribute("data-overflow-right", "true");
expect(await scrollOwners()).toBe(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false);
});
test("production organization switcher changes selection without leaking task fixtures", async ({ page }, testInfo) => {
await openPage(page);
await page.getByRole("button", { name: "Open Paperclip Storybook organization switcher", exact: true }).click();
await expect(page.getByText("Organizations", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Edit", exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("task-list-organization-menu.png"), fullPage: true });
await page.getByRole("menuitem", { name: /Research Bureau/ }).click();
await expect(page.getByRole("button", { name: "Open Research Bureau organization switcher", exact: true })).toBeVisible();
await expect(rows(page)).toHaveCount(0);
await expect(page.getByText("No tasks in this organization", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Open Research Bureau organization switcher", exact: true }).click();
await page.getByRole("menuitem", { name: /Paperclip Storybook/ }).click();
await expect(rows(page)).toHaveCount(10);
});
test("production account menu fits profile, settings, Invite and theme actions", async ({ page }, testInfo) => {
await openPage(page);
const trigger = page.getByRole("button", { name: "Open account menu", exact: true });
await trigger.click();
await expect(page.getByRole("link", { name: "View profile", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Settings", exact: true })).toBeVisible();
const invite = page.getByRole("link", { name: "Invite", exact: true });
await expect(invite).toBeVisible();
await expect(invite).toHaveAttribute("href", /\/company\/settings\/members\?tab=invites$/);
await expect(page.getByRole("link", { name: "Documentation", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Switch to light mode", exact: true })).toBeVisible();
const account = page.getByRole("dialog", { name: "Account", exact: true });
await expect(account.locator('[data-slot="avatar-fallback"]')).toHaveText("R");
const content = await account.evaluate(menu => {
const last = menu.querySelector('[data-flyout-options] > button:last-of-type')!;
return { bottomGap: menu.getBoundingClientRect().bottom - last.getBoundingClientRect().bottom, minimumHeight: getComputedStyle(menu).minHeight };
});
expect(content.minimumHeight).toBe("0px");
expect(content.bottomGap).toBe(13);
await page.screenshot({ path: testInfo.outputPath("task-list-account-menu.png"), fullPage: true });
await page.keyboard.press("Escape");
await expect(page.getByRole("link", { name: "View profile", exact: true })).toBeHidden();
await expect(trigger).toBeFocused();
await trigger.click();
await invite.click();
await expect(account).toBeHidden();
});
test("organization, account, Views and Columns share the refreshed flyout appearance", async ({ page }, testInfo) => {
await openPage(page);
const scenarios = [
{ trigger: "Open Paperclip Storybook organization switcher", role: "menu", name: /organization switcher/, option: /Research Bureau/, optionRole: "menuitem", screenshot: "flyout-organizations" },
{ trigger: "Open account menu", role: "dialog", name: "Account", option: "Settings", optionRole: "link", screenshot: "flyout-account" },
// Radix names this menu from its trigger, so the accessible name changes with the selected view.
{ trigger: "Task view: All tasks", role: "menu", name: undefined, option: "Unread", optionRole: "menuitemradio", screenshot: "flyout-views" },
{ trigger: "Columns", role: "menu", name: "Columns", option: "Created by", optionRole: "menuitemcheckbox", screenshot: "flyout-columns" },
] as const;
let sharedShadow: string | undefined;
for (const scenario of scenarios) {
await page.getByRole("button", { name: scenario.trigger, exact: true }).click();
const menu = page.getByRole(scenario.role, { name: scenario.name, exact: true });
await expect(menu).toHaveCSS("background-color", "rgb(26, 26, 26)");
await expect(menu).toHaveCSS("border-width", "1px");
await expect(menu).toHaveCSS("border-radius", "8px");
const shadow = await menu.evaluate(element => getComputedStyle(element).boxShadow);
expect(shadow).not.toBe("none");
if (sharedShadow) expect(shadow).toBe(sharedShadow);
sharedShadow = shadow;
const option = menu.getByRole(scenario.optionRole, { name: scenario.option, exact: true });
await expect(option).toHaveCSS("color", "oklch(0.708 0 0)");
const selected = menu.locator('[data-selected="true"], [aria-checked="true"]').first();
if (await selected.count()) {
await expect(selected).toHaveCSS("color", "oklch(0.708 0 0)");
await expect(selected.locator("svg.lucide-check")).toHaveCSS("color", "oklch(0.708 0 0)");
}
await option.hover();
await expect(option).toHaveCSS("background-color", "rgb(51, 51, 51)");
await expect(option).toHaveCSS("color", await menu.locator("[data-flyout-header]").evaluate(element => getComputedStyle(element).color));
if (scenario.trigger === "Task view: All tasks") {
const selected = menu.getByRole("menuitemradio", { name: "All tasks", exact: true });
await expect(selected).toBeChecked();
await expect(selected.locator("svg.lucide-check")).toBeVisible();
await expect(selected.locator("svg.lucide-circle")).toHaveCount(0);
}
await page.screenshot({ path: testInfo.outputPath(`${scenario.screenshot}.png`) });
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
}
});
test("separate sidebar search target opens the production global palette", async ({ page }) => {
await openPage(page);
const organization = page.getByRole("button", { name: "Open Paperclip Storybook organization switcher", exact: true });
const search = page.getByRole("button", { name: "Global search", exact: true });
await expect(organization.locator("svg.lucide-chevrons-up-down")).toHaveCount(0);
const organizationBounds = (await organization.boundingBox())!;
expect((await search.boundingBox())!.x).toBeGreaterThanOrEqual(organizationBounds.x + organizationBounds.width);
await search.click();
const input = page.getByPlaceholder("Search tasks, agents, projects...", { exact: true });
await expect(input).toBeVisible();
await expect(page.getByText("Organizations", { exact: true })).toBeHidden();
await input.fill("Codex");
await expect(page.getByRole("option", { name: /CodexCoder/ })).toBeVisible();
await page.keyboard.press("Escape");
await expect(input).toBeHidden();
await expect(page.getByRole("textbox", { name: "Search tasks", exact: true })).toHaveValue("");
});
test("sidebar truncated labels share the table marquee and fade without extra tab stops", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await openPage(page);
const organization = page.getByRole("button", { name: "Open Paperclip Storybook organization switcher", exact: true });
const label = organization.locator("[data-cell-marquee]");
const recent = page.locator(".design-sidebar-recents a").first().locator("[data-cell-marquee]");
for (const text of [label, recent]) {
await expect(text).toHaveAttribute("data-truncated", "true");
await expect(text).not.toHaveAttribute("tabindex");
expect(await text.locator(".cell-marquee-viewport").evaluate(element => getComputedStyle(element).maskImage)).toContain("linear-gradient");
await text.hover();
await expect(text).toHaveAttribute("data-revealing", "true");
await expect.poll(() => text.locator(".cell-marquee-content").evaluate(element => getComputedStyle(element).transform)).not.toBe("matrix(1, 0, 0, 1, 0, 0)");
await page.mouse.move(0, 0);
await expect(text).not.toHaveAttribute("data-revealing");
await expect(text.locator(".cell-marquee-content")).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)");
}
const fitting = page.getByRole("link", { name: "Dashboard", exact: true }).locator("[data-cell-marquee]");
await fitting.hover();
await expect(fitting).not.toHaveAttribute("data-truncated");
await expect(fitting).not.toHaveAttribute("data-revealing");
await page.mouse.move(0, 0);
await organization.focus();
await expect(label).toHaveAttribute("data-revealing", "true");
await page.keyboard.press("Escape");
await expect(label).not.toHaveAttribute("data-revealing");
});
test("fractionally clipped recent titles reveal even when scrollWidth equals clientWidth", async ({ page }, testInfo) => {
await openPage(page);
const task = page.locator(".design-sidebar-recents").getByRole("link", { name: "Review budget notifications", exact: true });
const label = task.locator("[data-cell-marquee]");
const dimensions = await label.evaluate(element => {
const content = element.querySelector(".cell-marquee-content")!;
const range = document.createRange();
range.selectNodeContents(content);
return { client: element.clientWidth, scroll: content.scrollWidth, viewport: element.getBoundingClientRect().width, text: range.getBoundingClientRect().width };
});
expect(dimensions.scroll).toBe(dimensions.client);
expect(dimensions.text).toBeGreaterThan(dimensions.viewport);
expect(dimensions.text - dimensions.viewport).toBeLessThan(1);
await expect(label).toHaveAttribute("data-truncated", "true");
const bounds = (await task.boundingBox())!;
// Hover the link padding, outside the text rectangle, just like the row's
// visible hover state. Small overflow must still reveal the full final glyph.
await page.mouse.move(bounds.x + 3, bounds.y + 3);
await expect(label).toHaveAttribute("data-revealing", "true");
await expect(label.locator(".cell-marquee-content")).toHaveCSS("overflow", "visible");
await expect.poll(() => label.locator(".cell-marquee-content").evaluate(element => getComputedStyle(element).transform)).not.toBe("matrix(1, 0, 0, 1, 0, 0)");
await page.locator(".design-sidebar").screenshot({ path: testInfo.outputPath("recent-task-marquee.png") });
await page.mouse.move(0, 0);
await expect(label).not.toHaveAttribute("data-revealing");
await expect(label.locator(".cell-marquee-content")).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)");
});
test("all-columns layout clips inside the table at a narrower desktop width", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1100, height: 862 });
await openPage(page, "all-columns");
await expect(page.getByRole("columnheader")).toHaveCount(9);
const overflow = await page.locator(".task-list-columns-scroll").evaluate(element => ({ table: element.scrollWidth > element.clientWidth, page: document.documentElement.scrollWidth > window.innerWidth }));
expect(overflow).toEqual({ table: true, page: false });
await expect(page.locator(".design-toolbar-actions")).toBeInViewport();
await page.getByRole("button", { name: "Task ID: sort ascending", exact: true }).click();
await expect(page.locator('.task-list-sort-button[data-active="true"]')).toHaveCount(1);
await page.getByRole("button", { name: "Columns", exact: true }).click();
await page.getByRole("menuitem", { name: "Reset defaults", exact: true }).click();
await page.keyboard.press("Escape");
await expect(page.getByRole("columnheader")).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(page.locator('.task-list-sort-button[data-active="true"]')).toHaveCount(0);
await expect(page.locator('[data-slot="task-list-sort-direction"]')).toHaveCount(0);
await expect(page.locator('[role="columnheader"][aria-sort="none"]')).toHaveCount(3);
await page.mouse.move(0, 0);
for (const button of await page.locator(".task-list-sort-button").all()) await expect(button).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await page.locator(".design-task-list-panel").screenshot({ path: testInfo.outputPath("task-list-reset-defaults.png") });
});
@@ -0,0 +1,20 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: "task-list-reproduction-trial.spec.ts",
outputDir: "./test-results/task-list-reproduction-trial",
workers: 1,
retries: 0,
timeout: 30_000,
use: {
baseURL: process.env.STORYBOOK_TEST_BASE_URL ?? "http://127.0.0.1:6007",
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
colorScheme: "dark",
reducedMotion: "reduce",
locale: "en-US",
timezoneId: "America/Los_Angeles",
launchOptions: { executablePath: process.env.TRIAL_CHROMIUM_EXECUTABLE },
},
});
@@ -0,0 +1,225 @@
import { readFileSync } from "node:fs";
import { expect, test, type Page } from "@playwright/test";
const storyId = "design-agent-task-list-trial";
const referencePng = process.env.TRIAL_REFERENCE_PNG;
if (!referencePng) throw new Error("TRIAL_REFERENCE_PNG is required: supply the screenshot frozen before implementation; visual verification cannot be omitted.");
const rows = (page: Page) => page.locator('.design-task-list-panel [data-slot="task-row"]');
const titles = (page: Page) => rows(page).locator('[data-slot="task-row-title"]');
test.beforeEach(async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-10-15T00:00:00.000Z"));
await page.goto(`/iframe.html?id=${storyId}&viewMode=story&globals=theme:dark`);
await expect(page.getByTestId("design-page-shell")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await page.mouse.move(1439, 899);
});
test("matches the pre-implementation frozen reference and mounts canonical components", async ({ page }, testInfo) => {
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await expect(rows(page)).toHaveCount(10);
await expect(page.getByRole("columnheader")).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(page.getByRole("textbox", { name: "Search tasks", exact: true })).toHaveValue("");
await expect(page.locator('.task-list-sort-button[data-active="true"]')).toHaveCount(0);
await expect(page.getByRole("separator", { name: "Resize sidebar" })).toHaveAttribute("aria-valuenow", "240");
const screenshot = await page.screenshot({ path: testInfo.outputPath("reproduction.png"), animations: "disabled" });
expect(screenshot.equals(readFileSync(referencePng))).toBe(true);
const components = await page.getByTestId("design-page-shell").evaluate(element => {
const key = Object.keys(element).find(key => key.startsWith("__reactFiber$"))!;
type Fiber = { type?: { name?: string; displayName?: string }; return?: Fiber; child?: Fiber; sibling?: Fiber };
let root = (element as unknown as Record<string, Fiber>)[key]!;
while (root.return) root = root.return;
const names = new Set<string>();
const visit = (fiber?: Fiber) => {
if (!fiber) return;
const name = fiber.type?.displayName ?? fiber.type?.name;
if (name) names.add(name);
visit(fiber.child);
visit(fiber.sibling);
};
visit(root);
return [...names];
});
for (const component of ["DesignPageShell", "SidebarShell", "DesignSidebar", "SidebarCompanyMenu", "SidebarAccountMenu", "TaskListToolbar", "TaskList", "TaskListViewport", "TaskListColumnHeader", "TaskListColumnSettings", "TaskRowGroup", "IssueRow", "CellMarquee"]) {
expect(components).toContain(component);
}
const controller = readFileSync("ui/storybook/support/design-proposals/TasksReproductionTrial.tsx", "utf8");
expect(controller).not.toMatch(/TaskListPagePreview|style=|<button|<input|<table|<svg|#[0-9a-f]{6}|\[\d+px\]/i);
expect(errors).toEqual([]);
const index = await (await page.request.get("/index.json")).json();
expect(index.entries[storyId].title).toBe("Design proposals/AI Generated Test Pages/Tasks reproduction test");
});
for (const target of [storyId, "tasks-list-page-design--default"]) {
test(`${target}: creator filters match their identity and collapsed matches become visible`, async ({ page }) => {
await page.goto(`/iframe.html?id=${target}&viewMode=story&globals=theme:dark`);
await expect(rows(page)).toHaveCount(10);
await page.getByRole("button", { name: "Filter", exact: true }).click();
await page.getByRole("region", { name: "Creator", exact: true }).getByRole("checkbox", { name: "Scott", exact: true }).click();
await page.keyboard.press("Escape");
await expect(rows(page)).toHaveCount(6);
await page.getByRole("button", { name: "1", exact: true }).click();
const creator = page.getByRole("region", { name: "Creator", exact: true });
await creator.getByRole("checkbox", { name: "Scott", exact: true }).click();
await creator.getByRole("checkbox", { name: "CodexCoder", exact: true }).click();
await page.keyboard.press("Escape");
await expect(rows(page)).toHaveCount(5);
await page.getByRole("button", { name: "1", exact: true }).click();
await page.getByRole("region", { name: "Creator", exact: true }).getByRole("checkbox", { name: "CodexCoder", exact: true }).click();
await page.keyboard.press("Escape");
await expect(rows(page)).toHaveCount(10);
await page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true }).click();
await page.getByRole("textbox", { name: "Search tasks", exact: true }).fill("expired credential");
await expect(rows(page)).toHaveCount(3);
await expect(titles(page)).toHaveText(["Improve connector reliability", "Design reconnect and recovery flows", /Document connector recovery/]);
});
test(`${target}: metadata search, view/filter expansion and board matching share the collection contract`, async ({ page }) => {
await page.goto(`/iframe.html?id=${target}&viewMode=story&globals=theme:dark`);
await expect(rows(page)).toHaveCount(10);
const search = page.getByRole("textbox", { name: "Search tasks", exact: true });
await search.fill("CodexCoder");
await expect(rows(page)).toHaveCount(8);
await search.fill("");
await page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true }).click();
await page.getByRole("button", { name: "Task view: All tasks", exact: true }).click();
await page.getByRole("menuitemradio", { name: "Unread", exact: true }).click();
await expect(rows(page)).toHaveCount(3);
await expect(page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true })).toHaveAttribute("aria-expanded", "true");
await page.getByRole("button", { name: "Task view: Unread", exact: true }).click();
await page.getByRole("menuitemradio", { name: "All tasks", exact: true }).click();
await page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true }).click();
await page.getByRole("button", { name: "Filter", exact: true }).click();
await page.getByRole("checkbox", { name: "In Review", exact: true }).click();
await page.keyboard.press("Escape");
await expect(rows(page)).toHaveCount(3);
await expect(page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true })).toHaveAttribute("aria-expanded", "true");
await page.getByRole("button", { name: "Board view", exact: true }).click();
const board = page.locator(".design-task-board");
await expect(board.getByText("Define connector health states", { exact: true })).toBeVisible();
await expect(board.getByText("Review budget notifications", { exact: true })).toBeVisible();
await expect(board.getByText("Improve connector reliability", { exact: true })).toHaveCount(0);
});
}
test("read/unread hover and nested guides retain canonical behavior", async ({ page }, testInfo) => {
const unread = titles(page).filter({ hasText: /^Improve connector reliability$/ });
const read = titles(page).filter({ hasText: /^Design reconnect and recovery flows$/ });
await expect(unread).toHaveCSS("font-weight", "600");
await expect(read).toHaveCSS("font-weight", "400");
const foreground = await unread.evaluate(element => getComputedStyle(element).color);
expect(await read.evaluate(element => getComputedStyle(element).color)).not.toBe(foreground);
for (const [title, weight] of [[read, "400"], [unread, "600"]] as const) {
const background = () => title.evaluate(element => getComputedStyle(element.closest('[data-slot="task-row"]')!).backgroundColor);
const originalBackground = await background();
const bounds = (await title.boundingBox())!;
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await expect(title).toHaveCSS("color", foreground);
await expect(title).toHaveCSS("font-weight", weight);
expect(await background()).not.toBe(originalBackground);
}
await page.screenshot({ path: testInfo.outputPath("unread-hover.png"), animations: "disabled" });
const guides = page.locator('[data-slot="task-row-tree-guide"]');
await expect(guides).toHaveCount(5);
const positions = await titles(page).evaluateAll(elements => elements.map(element => element.getBoundingClientRect().x));
expect(positions[1] - positions[0]).toBe(24);
expect(positions[3] - positions[1]).toBe(24);
const nested = page.getByRole("button", { name: "Collapse Design reconnect and recovery flows", exact: true });
await expect(nested).toHaveAttribute("aria-expanded", "true");
await nested.click();
await expect(rows(page)).toHaveCount(9);
await page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true }).click();
await expect(rows(page)).toHaveCount(6);
await page.getByRole("button", { name: "Expand all tasks", exact: true }).click();
await expect(rows(page)).toHaveCount(10);
await expect(nested).toHaveAttribute("aria-expanded", "true");
await page.getByRole("button", { name: "Collapse all tasks", exact: true }).click();
await expect(rows(page)).toHaveCount(6);
await page.getByRole("button", { name: "Expand all tasks", exact: true }).click();
await expect(rows(page)).toHaveCount(10);
});
test("sorting, column toggles, reorder/reset and list/board state", async ({ page }) => {
await page.getByRole("button", { name: "Task ID: sort ascending", exact: true }).click();
await page.getByRole("button", { name: "Task ID: sort descending", exact: true }).click();
await expect(titles(page).first()).toHaveText("Collect feedback on the refreshed task list");
await page.getByRole("button", { name: "Columns", exact: true }).click();
await page.getByRole("menuitemcheckbox", { name: "Created by", exact: true }).click();
await expect(page.getByRole("menuitemcheckbox", { name: "Created by", exact: true })).toBeChecked();
await page.keyboard.press("Escape");
await expect(page.getByRole("columnheader")).toHaveCount(4);
await page.getByRole("button", { name: "Board view", exact: true }).click();
await expect(page.getByRole("region", { name: "Task board", exact: true })).toBeVisible();
await page.getByRole("button", { name: "List view", exact: true }).click();
await expect(page.getByRole("columnheader")).toHaveCount(4);
await expect(titles(page).first()).toHaveText("Collect feedback on the refreshed task list");
await page.getByRole("button", { name: "Created by: sort ascending", exact: true }).press("Alt+ArrowLeft");
await expect(page.getByRole("columnheader")).toHaveText(["Task", "Created by", "Task ID", "Last updated"]);
await page.getByRole("button", { name: "Columns", exact: true }).click();
await page.getByRole("menuitem", { name: "Reset defaults", exact: true }).click();
await page.keyboard.press("Escape");
await expect(page.getByRole("columnheader")).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(page.locator('.task-list-sort-button[data-active="true"]')).toHaveCount(0);
await expect(titles(page).first()).toHaveText("Improve connector reliability");
});
test("views, searches and canonical filters preserve ancestors", async ({ page }) => {
await page.getByRole("button", { name: "Task view: All tasks", exact: true }).click();
await page.getByRole("menuitemradio", { name: "Unread", exact: true }).click();
await expect(rows(page)).toHaveCount(3);
await page.reload();
await expect(page.getByRole("button", { name: "Task view: Unread", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Task view: Unread", exact: true }).click();
await page.getByRole("menuitemradio", { name: "All tasks", exact: true }).click();
for (const [label, count] of [["Inbox", 6], ["Needs attention", 3]] as const) {
await page.getByRole("button", { name: "Task view: All tasks", exact: true }).click();
await page.getByRole("menuitemradio", { name: label, exact: true }).click();
await expect(rows(page)).toHaveCount(count);
await page.getByRole("button", { name: `Task view: ${label}`, exact: true }).click();
await page.getByRole("menuitemradio", { name: "All tasks", exact: true }).click();
}
const search = page.getByRole("textbox", { name: "Search tasks", exact: true });
await search.fill("expired credential");
await expect(rows(page)).toHaveCount(3);
await expect(page.locator(".design-task-results")).toHaveText("1 matching task · Parent tasks included for context");
await search.fill("no such task");
await expect(page.getByText("No tasks match this view", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear filters", exact: true }).click();
await expect(rows(page)).toHaveCount(10);
await page.getByRole("button", { name: "Filter", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search projects", exact: true })).toBeVisible();
await page.getByRole("checkbox", { name: "In Review", exact: true }).click();
await page.keyboard.press("Escape");
await expect(rows(page)).toHaveCount(3);
});
test("canonical organization/account menus and sidebar resizing", async ({ page }) => {
await page.getByRole("button", { name: "Global search", exact: true }).click();
await expect(page.getByPlaceholder("Search tasks, agents, projects...", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("toolbar", { name: "Task list controls" }).getByRole("button", { name: "New Task", exact: true }).click();
await expect(page.getByRole("dialog", { name: "New task", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Open account menu", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Account", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Invite", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Open Paperclip Storybook organization switcher", exact: true }).click();
await expect(page.getByRole("menuitem", { name: /Research Bureau/ })).toBeVisible();
await page.getByRole("menuitem", { name: /Research Bureau/ }).click();
await expect(rows(page)).toHaveCount(0);
await expect(page.getByText("No tasks in this organization", { exact: true })).toBeVisible();
await expect(page.locator(".design-sidebar-recents a")).toHaveCount(0);
const resize = page.getByRole("separator", { name: "Resize sidebar", exact: true });
await resize.press("ArrowRight");
await expect(resize).toHaveAttribute("aria-valuenow", "256");
await page.reload();
await expect(resize).toHaveAttribute("aria-valuenow", "256");
const bounds = (await resize.boundingBox())!;
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width / 2 + 32, bounds.y + bounds.height / 2);
await page.mouse.up();
await expect(resize).toHaveAttribute("aria-valuenow", "288");
});
@@ -0,0 +1,701 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
const longTitle = "Document connector recovery behavior when an expired credential interrupts an active task and requires a human to reconnect the account";
test.use({ viewport: { width: 1512, height: 862 } });
function rowFor(page: Page, number: number): Locator {
return page.locator('[data-slot="task-row"]').filter({
has: page.getByRole("link", { name: new RegExp(`^Open PAP-${number}:`) }),
});
}
async function openStory(page: Page, name = "specimen") {
await page.goto(`/iframe.html?id=tasks-row-design--${name}&viewMode=story&globals=theme:dark`);
await expect(page.getByTestId("task-row-design-preview")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await page.mouse.move(0, 0);
}
async function titleOverflows(title: Locator) {
return title.evaluate(element => element.querySelector('[data-slot="task-row-title-text"]')!.scrollWidth > element.clientWidth);
}
test("playground changes density and width independently without losing hierarchy or title states", async ({ page }, testInfo) => {
await openStory(page);
const preview = page.getByTestId("task-row-playground-container");
const row = preview.locator('[data-slot="task-row"]').first();
const widths = page.getByRole("group", { name: "Container width", exact: true });
const densities = page.getByRole("group", { name: "Row density", exact: true });
for (const { label, height } of [{ label: "Comfortable (44px)", height: "44px" }, { label: "Compact (32px)", height: "32px" }]) {
await densities.getByRole("button", { name: label, exact: true }).click();
await expect(densities.getByRole("button", { name: label, exact: true })).toHaveAttribute("aria-pressed", "true");
for (const width of ["Standard", "Narrow", "Smallest"]) {
await widths.getByRole("button", { name: width, exact: true }).click();
await expect(widths.getByRole("button", { name: width, exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(preview.locator('[data-slot="task-row"]')).toHaveCount(9);
await expect(row).toHaveCSS("height", height);
await expect(row.locator('[data-slot="task-row-identifier"]')).toBeVisible({ visible: width === "Standard" });
await expect(row.locator('[data-slot="task-row-metadata"]')).toBeVisible({ visible: width === "Standard" });
await expect(row.locator('[data-slot="task-row-timestamp"]')).toBeVisible({ visible: width !== "Smallest" });
await expect(rowFor(page, 431).locator('[data-slot="task-row-tree-guide"]')).toHaveCount(2);
for (const { number, weight } of [{ number: 456, weight: "400" }, { number: 457, weight: "600" }]) {
const title = rowFor(page, number).locator('[data-slot="task-row-title"]');
await expect(title).toHaveCSS("font-size", "14px");
await expect(title).toHaveCSS("line-height", "16px");
await expect(title).toHaveCSS("font-weight", weight);
}
const fit = await preview.evaluate(element => element.scrollWidth <= element.clientWidth);
expect(fit).toBe(true);
}
}
await page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true }).click();
await widths.getByRole("button", { name: "Narrow", exact: true }).click();
await expect(preview.locator('[data-slot="task-row"]')).toHaveCount(5);
await page.getByRole("button", { name: "Expand Improve connector reliability", exact: true }).click();
await expect(preview.locator('[data-slot="task-row"]')).toHaveCount(9);
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath("task-row-playground.png"), fullPage: true });
});
test("summary rows fit narrow containers without changing density or read typography", async ({ page }, testInfo) => {
await openStory(page, "summary");
for (const { label, identifier, timestamp } of [
{ label: "Standard task summary", identifier: true, timestamp: true },
{ label: "Narrow task summary", identifier: false, timestamp: true },
{ label: "Smallest task summary", identifier: false, timestamp: false },
]) {
const list = page.getByRole("list", { name: label, exact: true });
const rows = list.locator('[data-slot="task-row"]');
await expect(rows).toHaveCount(3);
const row = rows.first();
await expect(row).toHaveCSS("height", "44px");
await expect(row.locator('[data-slot="task-row-title"]')).toHaveCSS("font-size", "14px");
await expect(row.locator('[data-slot="task-row-title"]')).toHaveCSS("line-height", "16px");
await expect(row.locator('[data-slot="task-row-title"]')).toHaveCSS("font-weight", "400");
await expect(row.locator('[data-slot="task-row-identifier"]')).toBeVisible({ visible: identifier });
await expect(row.locator('[data-slot="task-row-timestamp"]')).toBeVisible({ visible: timestamp });
const fit = await list.evaluate(element => ({ content: element.scrollWidth, available: element.clientWidth }));
expect(fit.content).toBeLessThanOrEqual(fit.available);
await expect(list.locator('[data-testid="issue-row-unread-slot"], [data-slot="avatar"], [role="columnheader"]')).toHaveCount(0);
}
const list = page.getByRole("list", { name: "Narrow task summary", exact: true });
const row = list.locator('[data-slot="task-row"]').first();
const title = row.locator('[data-slot="task-row-title"]');
expect(await titleOverflows(title)).toBe(true);
const originalColor = await title.evaluate(element => getComputedStyle(element).color);
const rect = (await title.boundingBox())!;
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
await expect(title).toHaveAttribute("data-revealing", "true");
await expect(title).toHaveCSS("font-weight", "400");
expect(await title.evaluate(element => getComputedStyle(element).color)).not.toBe(originalColor);
await page.mouse.move(0, 0);
await expect(title).not.toHaveAttribute("data-revealing");
const link = list.getByRole("link", { name: `Open PAP-431: ${longTitle}`, exact: true });
await link.focus();
await expect(title).toHaveAttribute("data-revealing", "true");
await link.press("Escape");
await expect(title).not.toHaveAttribute("data-revealing");
await page.getByRole("heading", { name: "Task summaries", exact: true }).click();
await page.screenshot({ path: testInfo.outputPath("task-summary-containers.png"), fullPage: true });
// Container queries also apply below the app's global sm breakpoint.
await page.setViewportSize({ width: 390, height: 844 });
await expect(list.locator('[data-slot="task-row-identifier"]').first()).toBeHidden();
await expect(list.locator('[data-slot="task-row-timestamp"]').first()).toBeVisible();
await expect(row).toHaveCSS("height", "44px");
await expect(row.locator('[data-slot="task-row-status"]')).toBeVisible();
});
for (const { story, density, height } of [
{ story: "specimen", density: "comfortable", height: 44 },
{ story: "compact", density: "compact", height: 32 },
]) {
test(`${density} rows retain nesting, aligned metadata, and one group boundary`, async ({ page }, testInfo) => {
await openStory(page, story);
const list = page.locator(`[aria-label="${density} task list"]`);
const rows = list.locator('[data-slot="task-row"]');
await expect(rows).toHaveCount(7);
const geometry = await rows.evaluateAll((elements) => elements.map((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
const left = (slot: string) => element.querySelector(`[data-slot="${slot}"]`)!.getBoundingClientRect().left;
return {
height: rect.height,
paddingLeft: style.paddingLeft,
paddingRight: style.paddingRight,
titleLeft: left("task-row-title"),
metadataLeft: left("task-row-metadata"),
identifierLeft: left("task-row-identifier"),
timestampLeft: left("task-row-timestamp"),
borderTop: style.borderTopWidth,
borderBottom: style.borderBottomWidth,
};
}));
for (const row of geometry) {
expect(row.height).toBe(height);
expect(row.paddingLeft).toBe("16px");
expect(row.paddingRight).toBe("16px");
expect(row.borderTop).toBe("0px");
expect(row.borderBottom).toBe("0px");
expect(row.metadataLeft).toBe(geometry[0].metadataLeft);
expect(row.identifierLeft).toBe(geometry[0].identifierLeft);
expect(row.timestampLeft).toBe(geometry[0].timestampLeft);
}
expect(geometry[1].titleLeft - geometry[0].titleLeft).toBe(24);
expect(geometry[2].titleLeft).toBe(geometry[1].titleLeft);
expect(geometry[3].titleLeft - geometry[2].titleLeft).toBe(24);
expect(geometry[4].titleLeft).toBe(geometry[1].titleLeft);
const groups = list.locator('[data-slot="task-row-group"]');
await expect(groups).toHaveCount(3);
await expect(groups.first()).toHaveCSS("border-top-width", "1px");
await expect(groups.first()).toHaveCSS("border-bottom-width", "1px");
await expect(groups.nth(1)).toHaveCSS("border-top-width", "0px");
expect((await groups.first().boundingBox())!.height).toBe(height * 5 + 2);
// The outer guide stays continuous through child and grandchild rows.
const guides = await Promise.all([429, 430, 431, 432].map(async (number) =>
rowFor(page, number).locator('[data-slot="task-row-tree-connector"]').first().boundingBox(),
));
for (let index = 0; index < guides.length; index += 1) {
expect(guides[index]!.x).toBe(guides[0]!.x);
expect(guides[index]!.height).toBe(height);
if (index > 0) expect(guides[index - 1]!.y + guides[index - 1]!.height).toBe(guides[index]!.y);
}
const depthTwoGuides = await rowFor(page, 431).locator('[data-slot="task-row-tree-connector"]').evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().left));
expect(depthTwoGuides[1] - depthTwoGuides[0]).toBe(24);
await expect(rowFor(page, 430).locator('[data-chevron-gap="true"]')).toHaveCount(1);
if (density === "comfortable") {
const path = testInfo.outputPath("task-row-specimen.png");
await page.screenshot({ path, fullPage: true });
await testInfo.attach("Task row specimen", { path, contentType: "image/png" });
}
});
}
test("read and unread preserve their weights through real pointer hover", async ({ page }) => {
await openStory(page);
const foreground = await page.getByRole("heading", { name: "Task rows", exact: true }).evaluate((element) => getComputedStyle(element).color);
const muted = await page.getByText("Read · Regular", { exact: true }).evaluate((element) => getComputedStyle(element).color);
expect(foreground).not.toBe(muted);
for (const { number, weight, color } of [
{ number: 456, weight: "400", color: muted },
{ number: 457, weight: "600", color: foreground },
]) {
const row = rowFor(page, number);
const title = row.locator('[data-slot="task-row-title"]');
await page.mouse.move(0, 0);
await expect(title).toHaveCSS("font-weight", weight);
await expect(title).toHaveCSS("color", color);
const background = await row.evaluate((element) => getComputedStyle(element).backgroundColor);
await row.hover();
await expect(title).toHaveCSS("font-weight", weight);
await expect(title).toHaveCSS("color", foreground);
expect(await row.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe(background);
await page.mouse.move(0, 0);
await expect(title).toHaveCSS("color", color);
}
const identifier = rowFor(page, 428).locator('[data-slot="task-row-identifier"]');
expect(await identifier.evaluate((element) => getComputedStyle(element).fontFamily)).toContain("Paper Mono");
expect(await page.evaluate(() => document.fonts.check('12px "Paper Mono"'))).toBe(true);
});
test("title reveal follows truncation on resize and respects reduced motion", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await openStory(page);
const row = rowFor(page, 430);
const title = row.locator('[data-slot="task-row-title"]');
const link = row.locator('[data-inbox-issue-link]');
const text = title.locator('[data-slot="task-row-title-text"]');
expect(await titleOverflows(title)).toBe(false);
await link.hover();
await expect(title).not.toHaveAttribute("data-revealing");
await expect(page.getByRole("tooltip")).toHaveCount(0);
await page.mouse.move(0, 0);
await link.focus();
await expect(link).toBeFocused();
await expect(title).not.toHaveAttribute("data-revealing");
await expect(page.getByRole("tooltip")).toHaveCount(0);
await page.setViewportSize({ width: 660, height: 862 });
await expect.poll(() => titleOverflows(title)).toBe(true);
await expect(title).toHaveAttribute("data-revealing", "true");
await expect(text).toHaveCSS("transition-duration", "0s");
await expect.poll(async () => Math.abs((await text.boundingBox())!.x + (await text.boundingBox())!.width - (await title.boundingBox())!.x - (await title.boundingBox())!.width)).toBeLessThan(1);
await expect(page.getByRole("tooltip")).toHaveCount(0);
await page.setViewportSize({ width: 1512, height: 862 });
await expect.poll(() => titleOverflows(title)).toBe(false);
await expect(title).not.toHaveAttribute("data-revealing");
await expect(text).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)");
await expect(link).toBeFocused();
});
test("all nine columns use sm text and align through nesting and horizontal scrolling", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1200, height: 862 });
await openStory(page, "all-columns");
const header = page.locator('[data-slot="task-list-column-header"]');
await expect(header.locator('[data-column]')).toHaveText([
"Task", "Task ID", "Created by", "Assignee", "Project", "Parent Task", "Tags", "Worktree", "Last updated",
]);
await expect(header).toHaveCSS("height", "44px");
const columns = ["taskId", "createdBy", "assignee", "project", "parentTask", "tags", "lastUpdated", "worktree"];
const rows = page.locator('[data-slot="task-row"]');
await expect(rows).toHaveCount(7);
await expect(header.getByRole("button", { name: /^Status:/ })).toHaveCount(0);
await expect(header.locator('[data-column="task"]')).toHaveAttribute("aria-colspan", "2");
const typeSizes = await page.locator('[data-slot="task-row-title"], [data-slot="task-row-columns"] [data-column], [data-slot="task-row-columns"] [data-slot="badge"], .task-list-sort-button > span').evaluateAll(elements => elements.map(element => ({ size: getComputedStyle(element).fontSize, leading: getComputedStyle(element).lineHeight })));
for (const type of typeSizes) expect(type).toEqual({ size: "14px", leading: "16px" });
for (const column of columns) {
const heading = await header.locator(`[data-column="${column}"]`).boundingBox();
const cells = await rows.locator(`[data-column="${column}"]`).evaluateAll(elements => elements.map(element => element.getBoundingClientRect().toJSON()));
expect(cells).toHaveLength(7);
for (const cell of cells) {
expect(cell.x).toBe(heading!.x);
expect(cell.width).toBe(heading!.width);
}
}
const rootTitle = await rowFor(page, 428).locator('[data-slot="task-row-title"]').boundingBox();
expect(rootTitle!.x).toBe((await header.locator('[data-column="task"]').getByRole("button").locator("span").boundingBox())!.x);
for (const number of [428, 429, 431]) {
const row = rowFor(page, number);
const status = await row.locator('[data-slot="task-row-status"]').boundingBox();
const title = await row.locator('[data-slot="task-row-title"]').boundingBox();
expect(title!.x - status!.x - status!.width).toBe(8);
}
expect((await rowFor(page, 431).locator('[data-slot="task-row-title"]').boundingBox())!.x - rootTitle!.x).toBe(48);
await expect(rowFor(page, 455).locator('[data-column="assignee"]')).toHaveText("Unassigned");
await expect(rowFor(page, 455).locator('[data-column="worktree"]')).toHaveText("—");
await expect(rowFor(page, 431).locator('[data-column="parentTask"]')).toContainText("PAP-430");
await expect(rowFor(page, 428).locator('[data-column="worktree"]')).toHaveText("codex/pap-428");
const scroller = page.getByRole("region", { name: "Task list columns" });
const viewport = page.locator('[data-slot="task-list-viewport"]');
expect(await scroller.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true);
await expect(viewport).toHaveAttribute("data-overflow-left", "false");
await expect(viewport).toHaveAttribute("data-overflow-right", "true");
await scroller.focus();
await expect(scroller).toBeFocused();
await scroller.evaluate(element => element.scrollTo({ left: (element.scrollWidth - element.clientWidth) / 2 }));
await expect(viewport).toHaveAttribute("data-overflow-left", "true");
await expect(viewport).toHaveAttribute("data-overflow-right", "true");
await header.locator('[data-column="worktree"]').scrollIntoViewIfNeeded();
expect(await scroller.evaluate(element => element.scrollLeft)).toBeGreaterThan(0);
await scroller.evaluate(element => element.scrollTo({ left: element.scrollWidth }));
await expect(viewport).toHaveAttribute("data-overflow-left", "true");
await expect(viewport).toHaveAttribute("data-overflow-right", "false");
await expect(header.locator('[data-column]')).toHaveCount(9);
await page.setViewportSize({ width: 1700, height: 862 });
await expect.poll(() => scroller.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(false);
await expect(viewport).toHaveAttribute("data-overflow-left", "false");
await expect(viewport).toHaveAttribute("data-overflow-right", "false");
await page.getByRole("heading", { name: "Task-list columns", exact: true }).click();
const path = testInfo.outputPath("task-list-all-columns.png");
await page.locator('.task-list-columns-content').screenshot({ path });
await testInfo.attach("Complete column specimen", { path, contentType: "image/png" });
});
test("headers sort both ways without separating subtasks from their parents", async ({ page }, testInfo) => {
await openStory(page, "all-columns");
const header = page.locator('[data-slot="task-list-column-header"]');
await expect(header.getByRole("button", { name: /^Status:/ })).toHaveCount(0);
const ids = page.locator('[data-slot="task-row"] [data-column="taskId"]');
const taskId = header.locator('[data-column="taskId"]');
await expect(taskId).toHaveAttribute("aria-sort", "none");
await expect(header.locator('[data-active="true"]')).toHaveCount(0);
await expect(header.locator('[data-slot="task-list-sort-direction"]')).toHaveCount(0);
await taskId.getByRole("button").click();
await expect(taskId).toHaveAttribute("aria-sort", "ascending");
for (const button of await header.getByRole("button").all()) {
await expect(button).toHaveCSS("padding-left", "8px");
await expect(button).toHaveCSS("padding-right", "8px");
}
await expect(taskId.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("width", "12px");
await expect(taskId.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("height", "12px");
await taskId.getByRole("button").click();
await expect(taskId).toHaveAttribute("aria-sort", "descending");
await expect(ids).toHaveText(["PAP-455", "PAP-454", "PAP-428", "PAP-432", "PAP-430", "PAP-431", "PAP-429"]);
await expect(rowFor(page, 431).locator('[data-slot="task-row-tree-guide"]')).toHaveCount(2);
const collapse = page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true });
await collapse.click();
await expect(ids).toHaveText(["PAP-455", "PAP-454", "PAP-428"]);
await page.getByRole("button", { name: "Expand Improve connector reliability", exact: true }).click();
await expect(ids).toHaveText(["PAP-455", "PAP-454", "PAP-428", "PAP-432", "PAP-430", "PAP-431", "PAP-429"]);
await taskId.getByRole("button").focus();
await page.keyboard.press("Enter");
await expect(taskId).toHaveAttribute("aria-sort", "ascending");
await expect(ids).toHaveText(["PAP-428", "PAP-429", "PAP-430", "PAP-431", "PAP-432", "PAP-454", "PAP-455"]);
const title = header.locator('[data-column="task"]');
const titleLabel = title.getByRole("button").locator("span");
const labelBefore = await titleLabel.boundingBox();
await title.getByRole("button").hover();
await expect(title.getByRole("button")).toHaveCSS("padding-left", "8px");
await expect(title.getByRole("button")).toHaveCSS("padding-right", "8px");
expect(await titleLabel.boundingBox()).toEqual(labelBefore);
await title.getByRole("button").click();
await expect(title).toHaveAttribute("aria-sort", "ascending");
await expect(taskId).toHaveAttribute("aria-sort", "none");
await expect(ids).toHaveText(["PAP-454", "PAP-428", "PAP-429", "PAP-430", "PAP-431", "PAP-432", "PAP-455"]);
await title.getByRole("button").click();
await expect(title).toHaveAttribute("aria-sort", "descending");
await expect(ids).toHaveText(["PAP-455", "PAP-428", "PAP-432", "PAP-430", "PAP-431", "PAP-429", "PAP-454"]);
await page.mouse.move(0, 0);
await expect(title.getByRole("button")).toHaveCSS("background-color", "rgb(51, 51, 51)");
const path = testInfo.outputPath("task-list-task-header.png");
await page.locator('[data-slot="task-list-viewport"]').screenshot({ path });
await testInfo.attach("Combined Task header and sm table typography", { path, contentType: "image/png" });
for (const column of ["createdBy", "assignee", "project", "parentTask", "tags", "lastUpdated", "worktree"]) {
const cell = header.locator(`[data-column="${column}"]`);
await cell.getByRole("button").click();
await expect(cell).toHaveAttribute("aria-sort", "ascending");
await expect(cell.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("width", "12px");
await expect(cell.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("height", "12px");
await cell.getByRole("button").click();
await expect(cell).toHaveAttribute("aria-sort", "descending");
await expect(cell.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("width", "12px");
await expect(cell.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("height", "12px");
await expect(header.locator('[aria-sort="descending"]')).toHaveCount(1);
}
// A missing worktree stays last even when sorting descending.
await expect(ids).toHaveText(["PAP-454", "PAP-428", "PAP-432", "PAP-430", "PAP-431", "PAP-429", "PAP-455"]);
});
test("truncated cells reveal independently and preserve row navigation", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1800, height: 900 });
await page.emulateMedia({ reducedMotion: "no-preference" });
await openStory(page, "all-columns");
const row = rowFor(page, 431);
const title = row.locator('[data-slot="task-row-title"]');
const parent = row.locator('[data-column="parentTask"] [data-cell-marquee]');
const tags = rowFor(page, 429).locator('[data-column="tags"] [data-cell-marquee]');
const parentText = parent.locator('.cell-marquee-content');
const revealing = page.locator('[data-cell-marquee][data-revealing="true"]');
const moveToCell = async (cell: Locator) => {
const rect = (await cell.boundingBox())!;
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
};
const overflows = (cell: Locator) => cell.evaluate(element => element.querySelector('.cell-marquee-content')!.scrollWidth > element.clientWidth);
expect(await overflows(title)).toBe(true);
expect(await overflows(parent)).toBe(true);
expect(await overflows(tags)).toBe(true);
for (const cell of [title, parent, tags]) {
await expect(cell).toHaveAttribute("data-truncated", "true");
await expect(cell.locator('.cell-marquee-viewport')).not.toHaveCSS("mask-image", "none");
}
const fitting = row.locator('[data-column="taskId"] [data-cell-marquee]');
await expect(fitting).not.toHaveAttribute("data-truncated");
await expect(fitting.locator('.cell-marquee-viewport')).toHaveCSS("mask-image", "none");
const mask = await parent.locator('.cell-marquee-viewport').evaluate(element => getComputedStyle(element).maskImage);
const background = await row.evaluate(element => getComputedStyle(element).backgroundColor);
await moveToCell(fitting);
expect(await row.evaluate(element => getComputedStyle(element).backgroundColor)).not.toBe(background);
await expect(parent.locator('.cell-marquee-viewport')).toHaveCSS("mask-image", mask);
await expect(parent).not.toHaveAttribute("data-revealing");
await page.locator('[data-slot="task-list-viewport"]').screenshot({ path: testInfo.outputPath("task-list-truncation-fade-hover.png") });
await page.mouse.move(0, 0);
await row.locator('[data-inbox-issue-link]').focus();
await expect(title).toHaveAttribute("data-revealing", "true");
await moveToCell(parent);
await expect(parent).toHaveAttribute("data-revealing", "true");
await expect(title).not.toHaveAttribute("data-revealing");
await expect(revealing).toHaveCount(1);
await expect.poll(() => parentText.evaluate(element => new DOMMatrixReadOnly(getComputedStyle(element).transform).m41)).toBeLessThan(-1);
await parentText.evaluate(element => element.getAnimations().forEach(animation => animation.finish()));
await expect(parent).toHaveAttribute("data-reveal-complete", "true");
const endMask = await parent.locator('.cell-marquee-viewport').evaluate(element => getComputedStyle(element).maskImage);
expect(endMask).not.toBe(mask);
expect(endMask).not.toMatch(/transparent\)$|rgba\(0, 0, 0, 0\)\)$/);
const parentEnd = await parentText.boundingBox();
const parentViewport = await parent.boundingBox();
expect(Math.abs(parentEnd!.x + parentEnd!.width - parentViewport!.x - parentViewport!.width)).toBeLessThan(1);
await moveToCell(tags);
await expect(tags).toHaveAttribute("data-revealing", "true");
await expect(parent).not.toHaveAttribute("data-revealing");
await expect(parentText).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)");
await expect(parentText).toHaveCSS("transition-duration", "0s");
await expect(revealing).toHaveCount(1);
await expect(page.getByRole("tooltip")).toHaveCount(0);
await moveToCell(rowFor(page, 429).locator('[data-column="taskId"]'));
await expect(revealing).toHaveCount(0);
await parent.focus();
await expect(parent).toHaveAttribute("data-revealing", "true");
await page.keyboard.press("Escape");
await expect(parent).not.toHaveAttribute("data-revealing");
await page.emulateMedia({ reducedMotion: "reduce" });
await moveToCell(parent);
await expect(parent).toHaveAttribute("data-revealing", "true");
await expect(parentText).toHaveCSS("transition-duration", "0s");
const cell = (await row.locator('[data-column="parentTask"]').boundingBox())!;
await page.mouse.click(cell.x + cell.width / 2, cell.y + cell.height / 2);
await expect(page.getByTestId("task-row-design-route")).toHaveText("/PAP/issues/PAP-431");
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath("task-list-cell-marquees.png") });
});
test("column headers drag in either direction, preserve sorting and nesting, and reset their order", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1800, height: 900 });
await openStory(page, "all-columns");
const header = page.locator('[data-slot="task-list-column-header"]');
const initialOrder = ["Task", "Task ID", "Created by", "Assignee", "Project", "Parent Task", "Tags", "Worktree", "Last updated"];
await expect(header.locator('[data-column]')).toHaveText(initialOrder);
const dragHeader = async (from: string, to: string) => {
const button = (await header.locator(`[data-column="${from}"] button`).boundingBox())!;
const target = (await header.locator(`[data-column="${to}"]`).boundingBox())!;
await page.mouse.move(button.x + button.width / 2, button.y + button.height / 2);
await page.mouse.down();
await page.mouse.move(button.x + button.width / 2 + 8, button.y + button.height / 2);
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 12 });
await page.mouse.up();
};
await dragHeader("taskId", "assignee");
await expect(header.locator('[data-column]')).toHaveText(["Task", "Created by", "Assignee", "Task ID", "Project", "Parent Task", "Tags", "Worktree", "Last updated"]);
await expect(header.locator('[data-column="taskId"]')).toHaveAttribute("aria-sort", "none");
await dragHeader("task", "project");
await expect(header.locator('[data-column]')).toHaveText(["Created by", "Assignee", "Task ID", "Project", "Task", "Parent Task", "Tags", "Worktree", "Last updated"]);
const task = header.locator('[data-column="task"] button');
await task.focus();
await page.keyboard.press("Alt+ArrowLeft");
const movedOrder = ["Created by", "Assignee", "Task ID", "Task", "Project", "Parent Task", "Tags", "Worktree", "Last updated"];
await expect(header.locator('[data-column]')).toHaveText(movedOrder);
await expect(task).toBeFocused();
for (const id of ["createdBy", "assignee", "taskId", "project", "parentTask", "tags", "worktree", "lastUpdated"]) {
const rect = (await header.locator(`[data-column="${id}"]`).boundingBox())!;
for (const cell of await page.locator(`[data-slot="task-row"] [data-column="${id}"]`).all()) {
expect((await cell.boundingBox())!.x).toBe(rect.x);
}
}
const root = rowFor(page, 428);
const child = rowFor(page, 431);
expect((await child.locator('[data-slot="task-row-title"]').boundingBox())!.x - (await root.locator('[data-slot="task-row-title"]').boundingBox())!.x).toBe(48);
for (const row of [root, child]) {
const status = (await row.locator('[data-slot="task-row-status"]').boundingBox())!;
expect((await row.locator('[data-slot="task-row-title"]').boundingBox())!.x - status.x - status.width).toBe(8);
}
await page.keyboard.press("Enter");
await expect(header.locator('[data-column="task"]')).toHaveAttribute("aria-sort", "ascending");
await page.mouse.move(0, 0);
await expect(task).toHaveCSS("background-color", "rgb(51, 51, 51)");
await page.locator('[data-slot="task-list-viewport"]').screenshot({ path: testInfo.outputPath("task-list-reordered-columns.png") });
const settings = page.getByRole("button", { name: "Columns", exact: true });
await settings.click();
const menu = page.getByRole("menu", { name: "Columns", exact: true });
await expect(menu.getByRole("menuitemcheckbox")).toHaveText(movedOrder);
await menu.getByRole("menuitemcheckbox", { name: "Created by", exact: true }).click();
await menu.getByRole("menuitemcheckbox", { name: "Created by", exact: true }).click();
await expect(header.locator('[data-column]')).toHaveText(movedOrder);
await menu.getByRole("menuitem", { name: "Reset defaults", exact: true }).click();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(menu.getByRole("menuitemcheckbox")).toHaveText(initialOrder);
await expect(header.locator('[data-active="true"]')).toHaveCount(0);
await expect(header.locator('[data-slot="task-list-sort-direction"]')).toHaveCount(0);
await menu.getByRole("menuitemcheckbox", { name: "Worktree", exact: true }).click();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Worktree", "Last updated"]);
await page.keyboard.press("Escape");
// Drag cancellation must not change the order or toggle sorting.
const button = (await task.boundingBox())!;
await page.mouse.move(button.x + button.width / 2, button.y + button.height / 2);
await page.mouse.down();
await page.mouse.move(button.x + button.width / 2 + 100, button.y + button.height / 2);
await page.keyboard.press("Escape");
await page.mouse.up();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Worktree", "Last updated"]);
await expect(header.locator('[data-column="task"]')).toHaveAttribute("aria-sort", "none");
});
test("status labels appear on hover and focus while the icon still opens the task", async ({ page }, testInfo) => {
await openStory(page, "all-columns");
const status = rowFor(page, 428).locator('[data-slot="task-row-status"]');
const tooltip = page.getByRole("tooltip");
await status.hover();
await expect(tooltip).toHaveText("In Progress");
await page.mouse.move(0, 0, { steps: 3 });
await expect(tooltip).toHaveCount(0);
await status.focus();
await expect(tooltip).toHaveText("In Progress");
await page.keyboard.press("Escape");
await expect(tooltip).toHaveCount(0);
await expect(status).toBeFocused();
await rowFor(page, 429).locator('[data-slot="task-row-status"]').hover();
await expect(tooltip).toHaveText("In Review");
const path = testInfo.outputPath("task-status-tooltip.png");
await page.screenshot({ path });
await testInfo.attach("Task status label", { path, contentType: "image/png" });
await status.click();
await expect(page.getByTestId("task-row-design-route")).toHaveText("/PAP/issues/PAP-428");
});
test("pinned Columns menu hides columns, retains alignment, and resets defaults", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1200, height: 862 });
await openStory(page, "all-columns");
const header = page.locator('[data-slot="task-list-column-header"]');
const settings = page.getByRole("button", { name: "Columns", exact: true });
const settingsBefore = await settings.boundingBox();
await page.getByRole("region", { name: "Task list columns" }).evaluate(element => element.scrollTo({ left: element.scrollWidth }));
expect(await settings.boundingBox()).toEqual(settingsBefore);
await settings.click();
const menu = page.getByRole("menu", { name: "Columns", exact: true });
await expect(menu).toBeVisible();
// Match the Paper flyout while retaining the current combined Task contract.
await expect(menu).toHaveCSS("width", "200px");
await expect(menu).toHaveCSS("border-radius", "8px");
await expect(menu).toHaveCSS("border-width", "1px");
await expect(menu).toHaveCSS("border-style", "solid");
await expect(menu).toHaveCSS("background-color", "rgb(26, 26, 26)");
const borderColor = await menu.evaluate(element => getComputedStyle(element.querySelector(".task-list-options-heading")!).borderBottomColor);
await expect(menu).toHaveCSS("border-color", borderColor);
for (const surface of [page.locator("html"), page.locator("body"), page.locator("#storybook-root"), page.getByTestId("task-row-design-preview"), page.locator('[data-slot="task-list-viewport"]'), page.locator(".task-list-header-controls"), page.locator('[data-slot="task-row-group"]').first()]) {
await expect(surface).toHaveCSS("background-color", "rgb(26, 26, 26)");
}
const heading = menu.locator(".task-list-options-heading");
await expect(heading).toHaveCSS("padding", "12px 24px");
await expect(heading).toHaveCSS("font-weight", "600");
const options = menu.getByRole("menuitemcheckbox");
const menuGeometry = await options.evaluateAll(elements => elements.map(element => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const check = element.querySelector("svg")!.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, size: style.fontSize, leading: style.lineHeight, weight: style.fontWeight, radius: style.borderRadius, checkRight: rect.right - check.right, checkWidth: check.width };
}));
for (const [index, item] of menuGeometry.entries()) {
expect(item).toMatchObject({ width: 174, height: 32, size: "14px", leading: "20px", weight: "400", radius: "4px", checkRight: 12, checkWidth: 14 });
if (index > 0) expect(item.y - menuGeometry[index - 1].y).toBe(36);
}
const reset = menu.getByRole("menuitem", { name: "Reset defaults", exact: true });
await expect(reset).toHaveCSS("height", "32px");
await expect(reset).toHaveCSS("font-weight", "600");
const taskIdOption = menu.getByRole("menuitemcheckbox", { name: "Task ID", exact: true });
await taskIdOption.hover();
await expect(taskIdOption).toHaveCSS("background-color", "rgb(51, 51, 51)");
await expect(taskIdOption).toHaveCSS("color", await heading.evaluate(element => getComputedStyle(element).color));
await expect(menu.getByRole("menuitemcheckbox")).toHaveCount(9);
await expect(menu.getByRole("menuitemcheckbox", { name: "Status", exact: true })).toHaveCount(0);
await expect(menu.getByRole("menuitemcheckbox", { name: "Task", exact: true })).toBeChecked();
for (const name of ["Created by", "Tags", "Task ID"]) {
const item = menu.getByRole("menuitemcheckbox", { name, exact: true });
await expect(item).toBeChecked();
await item.click();
await expect(item).not.toBeChecked();
await expect(menu).toBeVisible();
}
await expect(header.locator('[data-column]')).toHaveText(["Task", "Assignee", "Project", "Parent Task", "Worktree", "Last updated"]);
await expect(page.locator('[data-slot="task-row"] [data-column="tags"]')).toHaveCount(0);
await expect(header.locator('[data-column="task"]')).toHaveAttribute("aria-sort", "none");
for (const column of ["assignee", "project", "parentTask", "lastUpdated", "worktree"]) {
const x = (await header.locator(`[data-column="${column}"]`).boundingBox())!.x;
const positions = await page.locator(`[data-slot="task-row"] [data-column="${column}"]`).evaluateAll(elements => elements.map(element => element.getBoundingClientRect().x));
expect(positions).toEqual(Array(7).fill(x));
}
// Hiding Task hides both status and title; hierarchy controls and links remain.
await menu.getByRole("menuitemcheckbox", { name: "Task", exact: true }).click();
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
await expect(rowFor(page, 428).locator('[data-slot="task-row-content"]')).toBeHidden();
await expect(rowFor(page, 428).getByRole("img", { name: "In Progress", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true }).click();
await expect(rowFor(page, 431)).toHaveCount(0);
await page.getByRole("button", { name: "Expand Improve connector reliability", exact: true }).click();
await expect(rowFor(page, 431)).toHaveCount(1);
await settings.click();
await menu.getByRole("menuitem", { name: "Reset defaults", exact: true }).click();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(header.locator('[data-column="taskId"]')).toHaveAttribute("aria-sort", "none");
await expect(menu.getByRole("menuitemcheckbox", { checked: true })).toHaveText(["Task", "Task ID", "Last updated"]);
for (const row of await page.locator('[data-slot="task-row"]').all()) {
expect(await row.locator('[data-column]').evaluateAll(elements => elements.map(element => element.getAttribute("data-column")))).toEqual(["taskId", "lastUpdated"]);
}
// Optional columns remain available after resetting to the smaller default.
const tags = menu.getByRole("menuitemcheckbox", { name: "Tags", exact: true });
await expect(tags).not.toBeChecked();
await tags.click();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Tags", "Last updated"]);
await expect(page.locator('[data-slot="task-row"] [data-column="tags"] [data-slot="badge"]').first()).toHaveCSS("background-color", "rgb(51, 51, 51)");
await tags.click();
// Leave only one column: the final choice is disabled until another is enabled.
await menu.getByRole("menuitemcheckbox", { name: "Worktree", exact: true }).click();
for (const name of ["Task", "Task ID", "Last updated"]) {
await menu.getByRole("menuitemcheckbox", { name, exact: true }).click();
}
await expect(menu.getByRole("menuitemcheckbox", { name: "Worktree", exact: true })).toBeDisabled();
await menu.getByRole("menuitem", { name: "Reset defaults", exact: true }).click();
await taskIdOption.hover();
const path = testInfo.outputPath("task-list-options.png");
await page.screenshot({ path });
await testInfo.attach("Columns flyout", { path, contentType: "image/png" });
await menu.screenshot({ path: testInfo.outputPath("task-list-columns-flyout.png") });
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
await expect(settings).toBeFocused();
await expect(rowFor(page, 428).locator('[data-slot="task-row-content"]')).toBeVisible();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(rowFor(page, 428).locator('[data-column="tags"]')).toHaveCount(0);
await page.locator('[data-slot="task-list-viewport"]').screenshot({ path: testInfo.outputPath("task-list-default-columns.png") });
});
test("truncated titles scroll once, hold their end, and reset immediately; row actions stay independent", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await openStory(page);
const route = page.getByTestId("task-row-design-route");
await expect(route).toHaveText("/PAP/storybook");
const longRow = rowFor(page, 431);
const title = longRow.locator('[data-slot="task-row-title"]');
await expect(title).toHaveCSS("text-overflow", "ellipsis");
await expect(title).toHaveCSS("white-space", "nowrap");
const text = title.locator('[data-slot="task-row-title-text"]');
expect(await titleOverflows(title)).toBe(true);
const start = (await text.boundingBox())!.x;
const identifierBefore = await longRow.locator('[data-slot="task-row-identifier"]').boundingBox();
const link = longRow.locator('[data-inbox-issue-link]');
await link.hover();
await expect(title).toHaveAttribute("data-revealing", "true");
await expect(page.getByRole("tooltip")).toHaveCount(0);
await expect.poll(async () => (await text.boundingBox())!.x).toBeLessThan(start - 1);
expect(await longRow.locator('[data-slot="task-row-identifier"]').boundingBox()).toEqual(identifierBefore);
await text.evaluate(element => element.getAnimations().forEach(animation => animation.finish()));
const viewport = await title.boundingBox();
const end = await text.boundingBox();
expect(Math.abs(end!.x + end!.width - viewport!.x - viewport!.width)).toBeLessThan(1);
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
expect(await text.boundingBox()).toEqual(end);
await page.mouse.move(0, 0);
await expect(title).not.toHaveAttribute("data-revealing");
await expect(text).toHaveCSS("transition-duration", "0s");
expect((await text.boundingBox())!.x).toBe(start);
await expect(title).toHaveCSS("text-overflow", "ellipsis");
// Reach the long-title link using the keyboard from the preceding disclosure.
const childDisclosure = page.getByRole("button", { name: "Collapse Design reconnect and recovery flows" });
await childDisclosure.focus();
await page.keyboard.press("Tab");
await expect(rowFor(page, 430).locator('[data-slot="task-row-status"]')).toBeFocused();
await expect(page.getByRole("tooltip")).toHaveText("Todo");
await page.keyboard.press("Tab");
await expect(link).toBeFocused();
await expect(title).toHaveAttribute("data-revealing", "true");
await expect(link).toHaveCSS("outline-style", "solid");
await expect(link).toHaveCSS("outline-width", "1px");
await page.keyboard.press("Escape");
await expect(title).not.toHaveAttribute("data-revealing");
await childDisclosure.click();
await expect(rowFor(page, 431)).toHaveCount(0);
await expect(rowFor(page, 432)).toBeVisible();
await expect(route).toHaveText("/PAP/storybook");
await page.getByRole("button", { name: "Expand Design reconnect and recovery flows" }).click();
await expect(rowFor(page, 431)).toBeVisible();
await page.getByRole("button", { name: "Collapse Improve connector reliability" }).click();
await expect(rowFor(page, 429)).toHaveCount(0);
await expect(rowFor(page, 431)).toHaveCount(0);
await expect(route).toHaveText("/PAP/storybook");
const rootDisclosure = page.getByRole("button", { name: "Expand Improve connector reliability" });
await rootDisclosure.focus();
await page.keyboard.press("Space");
await expect(rowFor(page, 431)).toBeVisible();
await expect(route).toHaveText("/PAP/storybook");
await rowFor(page, 428).locator('[data-inbox-issue-link]').focus();
await page.keyboard.press("Enter");
await expect(route).toHaveText("/PAP/issues/PAP-428");
});
@@ -0,0 +1,171 @@
import { expect, test, type Page } from "@playwright/test";
test.use({ viewport: { width: 1440, height: 1100 } });
async function openToolbars(page: Page, story = "playground") {
await page.goto(`/iframe.html?id=design-toolbars--${story}&viewMode=story&globals=theme:dark`);
await expect(page.getByTestId("toolbars-preview")).toBeVisible();
return page.getByTestId("toolbars-preview");
}
test("canonical toolbar stories mount the shared components under the proposal theme", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
for (const [story, count] of [["playground", 6], ["tasks", 1], ["decisions", 1], ["skills", 1], ["artifacts", 1], ["projects", 1], ["routines", 1], ["narrow", 6]] as const) {
const preview = await openToolbars(page, story);
await expect(page.locator(".design-proposal-theme")).toBeVisible();
await expect(preview.getByRole("toolbar")).toHaveCount(count);
}
expect(errors).toEqual([]);
});
test("task toolbar retains real view, search, layout, expansion and filter controls", async ({ page }) => {
const preview = await openToolbars(page, "tasks");
const toolbar = preview.getByRole("toolbar", { name: "Task list controls" });
await toolbar.getByRole("button", { name: "Task view: All tasks", exact: true }).click();
await page.getByRole("menuitemradio", { name: "Unread", exact: true }).click();
await expect(toolbar.getByRole("button", { name: "Task view: Unread", exact: true })).toBeVisible();
await toolbar.getByRole("textbox", { name: "Search tasks", exact: true }).fill("connector");
await toolbar.getByRole("button", { name: "Clear search", exact: true }).click();
await expect(toolbar.getByRole("textbox", { name: "Search tasks", exact: true })).toHaveValue("");
await toolbar.getByRole("button", { name: "Board view", exact: true }).click();
await expect(toolbar.getByRole("button", { name: "Board view", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(toolbar.getByRole("button", { name: /all tasks/ })).toBeDisabled();
await toolbar.getByRole("button", { name: "List view", exact: true }).click();
await toolbar.getByRole("button", { name: "Collapse all tasks", exact: true }).click();
await expect(toolbar.getByRole("button", { name: "Expand all tasks", exact: true })).toBeEnabled();
await toolbar.getByRole("button", { name: "Filter", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Filters", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await toolbar.getByRole("button", { name: "New Task", exact: true }).click();
await expect(preview.getByRole("status")).toContainText("New Task requested · local preview only");
});
test("decision menus update local state", async ({ page }) => {
const writes: string[] = [];
page.on("request", request => { if (["POST", "PATCH", "PUT", "DELETE"].includes(request.method()) && request.url().includes("/api/")) writes.push(request.url()); });
const preview = await openToolbars(page);
const decisions = preview.getByRole("region", { name: "Decisions toolbar", exact: true });
await decisions.getByRole("button", { name: "Group", exact: true }).click();
await page.getByRole("button", { name: "Severity", exact: true }).click();
await page.keyboard.press("Escape");
await expect(decisions.getByRole("status")).toContainText("Group: severity");
await decisions.getByRole("button", { name: "Sort", exact: true }).click();
await page.getByRole("button", { name: "Oldest first", exact: true }).click();
await page.keyboard.press("Escape");
await expect(decisions.getByRole("status")).toContainText("Sort: oldest");
await decisions.getByRole("button", { name: "Filter", exact: true }).click();
await page.getByRole("button", { name: "Critical", exact: true }).click();
await page.keyboard.press("Escape");
await expect(decisions.getByRole("status")).toContainText("1 active filters");
expect(writes).toEqual([]);
});
test("width controls expose narrow wrapping and keyboard focus", async ({ page }) => {
const preview = await openToolbars(page);
await preview.getByRole("combobox", { name: "Toolbar container width", exact: true }).selectOption("narrow");
const specimens = preview.getByTestId("toolbar-specimens");
await expect(specimens).toHaveAttribute("data-container-width", "narrow");
expect(await specimens.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
const search = preview.getByRole("textbox", { name: "Search tasks", exact: true });
await search.focus();
await page.keyboard.press("Tab");
const list = preview.getByRole("button", { name: "List view", exact: true });
await expect(list).toBeFocused();
await expect(list).not.toHaveCSS("box-shadow", "none");
});
test("Skills and Artifacts use their shared production controls with local preview state", async ({ page }) => {
let preview = await openToolbars(page, "skills");
await preview.getByRole("textbox", { name: "Search installed skills", exact: true }).fill("review");
await preview.getByRole("button", { name: "Sort Most agents", exact: true }).click();
await page.getByRole("menuitemradio", { name: "Alphabetical", exact: true }).click();
await expect(preview.getByRole("status")).toContainText("Search: review · Sort: alphabetical");
await preview.getByRole("button", { name: "Source All", exact: true }).click();
await page.getByRole("menuitemradio", { name: "GitHub", exact: true }).click();
await expect(preview.getByRole("status")).toContainText("Source: github");
const scan = preview.getByRole("button", { name: "Scan project workspaces for skills", exact: true });
await scan.click();
await expect(scan).toBeDisabled();
await preview.getByRole("button", { name: "Complete simulated scan", exact: true }).click();
await expect(scan).toBeEnabled();
await preview.getByRole("button", { name: "New", exact: true }).click();
await page.getByRole("menuitem", { name: "Create new skill", exact: true }).click();
await expect(preview.getByRole("status")).toContainText("Create skill requested");
await preview.getByRole("button", { name: "New folder", exact: true }).click();
await expect(preview.getByRole("status")).toContainText("New folder requested");
preview = await openToolbars(page, "artifacts");
await preview.getByRole("textbox", { name: "Search artifacts", exact: true }).fill("mockup");
await preview.getByRole("radio", { name: "Images", exact: true }).click();
await expect(preview.getByRole("radio", { name: "Images", exact: true })).toHaveCSS("font-size", "14px");
await expect(preview.getByRole("radio", { name: "Images", exact: true })).toHaveCSS("height", "32px");
await preview.getByTestId("artifact-group-control").click();
await page.getByTestId("artifact-group-option-parent_task").click();
await expect(preview.getByRole("status")).toHaveText("Search: mockup · Group: parent_task · Type: image");
await preview.getByRole("button", { name: "Clear artifact search", exact: true }).click();
await expect(preview.getByRole("textbox", { name: "Search artifacts", exact: true })).toHaveValue("");
});
test("canonical toolbars share control geometry and keep the requested keyboard order", async ({ page }) => {
const preview = await openToolbars(page);
const geometry = await preview.locator("[data-canonical-toolbar]").evaluateAll(toolbars => toolbars.map(toolbar => {
const search = toolbar.querySelector<HTMLElement>("[data-toolbar-search]")!;
const first = toolbar.querySelector("input, button")!;
const controls = [...toolbar.querySelectorAll<HTMLElement>("button:not([data-toolbar-clear], .canonical-tab), [data-toolbar-control]")].filter(button => !button.closest(".design-view-toggle"));
const style = getComputedStyle(search);
return { name: toolbar.getAttribute("data-canonical-toolbar"), searchHeight: search.getBoundingClientRect().height, gap: getComputedStyle(toolbar).columnGap,
border: style.borderColor, radius: style.borderRadius, first: first.getAttribute("aria-label"),
controls: controls.map(button => ({ height: button.getBoundingClientRect().height, border: getComputedStyle(button).borderColor, radius: getComputedStyle(button).borderRadius })) };
}));
expect(geometry).toHaveLength(6);
for (const toolbar of geometry) {
expect(toolbar.searchHeight).toBe(44);
expect(toolbar.gap).toBe("8px");
expect(toolbar.first).toBe(toolbar.name === "tasks" ? "Task view: All tasks" : toolbar.name === "skills" ? "Search installed skills" : `Search ${toolbar.name}`);
for (const control of toolbar.controls) {
expect(control.height).toBe(toolbar.searchHeight);
expect(control.border).toBe(toolbar.border);
expect(control.radius).toBe(toolbar.radius);
}
}
const skills = preview.getByRole("toolbar", { name: "Skills controls", exact: true });
const skillViews = skills.getByRole("navigation", { name: "Skills views" });
await expect(skillViews.getByRole("link")).toHaveText(["Installed", "Discover", "My Skills"]);
await expect(skillViews.getByRole("link", { name: "Installed", exact: true })).toHaveAttribute("aria-current", "page");
await expect(skillViews.getByRole("link", { name: "My Skills", exact: true })).toHaveAttribute("href", /\/skills\/studio$/);
await expect(skillViews.getByRole("link", { name: "Sources", exact: true })).toHaveCount(0);
await expect(skills.getByRole("button").last()).toHaveAccessibleName("New");
const decisions = preview.getByRole("region", { name: "Decisions toolbar", exact: true });
await decisions.getByRole("textbox", { name: "Search decisions", exact: true }).fill("des-3");
await expect(decisions.locator("[data-toolbar-count]")).toHaveText("1 decision");
await decisions.getByRole("button", { name: "Clear search", exact: true }).click();
await expect(decisions.locator("[data-toolbar-count]")).toHaveText("2 decisions");
});
test("Projects toolbar searches, sorts and reports creation locally", async ({ page }) => {
const preview = await openToolbars(page, "projects");
await preview.getByRole("textbox", { name: "Search projects", exact: true }).fill("design");
await preview.getByRole("button", { name: "Sort: Name", exact: true }).click();
await page.getByRole("button", { name: "Updated", exact: true }).click();
await page.keyboard.press("Escape");
await expect(preview.getByRole("status")).toContainText("Search: design · Sort: updated · Direction: desc");
await preview.getByRole("button", { name: "Add Project", exact: true }).click();
await expect(preview.getByRole("status")).toContainText("Add Project requested · local preview only");
});
test("routines toolbar updates search, sort, group and creation intent", async ({ page }) => {
const preview = await openToolbars(page, "routines");
await preview.getByRole("textbox", { name: "Search routines", exact: true }).fill("review");
await expect(preview.locator("output")).toContainText("Search: review");
await preview.getByRole("button", { name: "Sort", exact: true }).click();
await page.getByRole("button", { name: "Title", exact: true }).click();
await expect(preview.locator("output")).toContainText("Sort: title · Direction: asc");
await preview.getByRole("button", { name: "Group", exact: true }).click();
await page.getByRole("button", { name: "Agent", exact: true }).click();
await expect(preview.locator("output")).toContainText("Group: assignee");
await expect(preview.getByRole("button", { name: "New folder" })).toHaveCount(0);
await preview.getByRole("button", { name: "Create routine" }).click();
await expect(preview.locator("output")).toContainText("Create routine requested");
});
@@ -0,0 +1,357 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { expect, test, type Page } from "@playwright/test";
const evidence = process.env.TRIAL_2_EVIDENCE_DIR;
if (!evidence) throw new Error("TRIAL_2_EVIDENCE_DIR is required; visual evidence may not be omitted.");
const reference = join(evidence, "reference.png");
const clock = "2026-10-15T00:00:00.000Z";
test.use({
viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1,
colorScheme: "dark", reducedMotion: "reduce", locale: "en-US",
timezoneId: "America/Los_Angeles",
launchOptions: { executablePath: process.env.TRIAL_CHROMIUM_EXECUTABLE },
});
const storyId = "design-agent-task-list-trial-2--default";
const rows = (page: Page) => page.locator('.design-task-list-panel [data-slot="task-row"]');
const titles = (page: Page) => rows(page).locator('[data-slot="task-row-title"]');
async function selectView(page: Page, label: string) {
await page.getByRole("button", { name: /^Task view:/ }).click();
await page.getByRole("menuitemradio", { name: label, exact: true }).click();
}
async function componentNames(page: Page) {
return page.getByTestId("design-page-shell").evaluate(element => {
const key = Object.keys(element).find(key => key.startsWith("__reactFiber$"))!;
type Fiber = { type?: { name?: string; displayName?: string }; return?: Fiber; child?: Fiber; sibling?: Fiber };
let root = (element as unknown as Record<string, Fiber>)[key]!;
while (root.return) root = root.return;
const names = new Set<string>();
const visit = (fiber?: Fiber) => {
if (!fiber) return;
const name = fiber.type?.displayName ?? fiber.type?.name;
if (name) names.add(name);
visit(fiber.child);
visit(fiber.sibling);
};
visit(root);
return [...names];
});
}
test.describe("independent second trial", () => {
test.skip(process.env.TRIAL_2_FREEZE === "1", "Freeze runs before implementation exists.");
test.beforeEach(async ({ page }) => {
await openStory(page, storyId);
});
test("pixel comparison executes against the immutable frozen reference", async ({ page, browser }, testInfo) => {
expect(existsSync(reference), "Frozen reference must exist; do not substitute a new baseline").toBe(true);
const frozen = readFileSync(reference);
const conditions = JSON.parse(readFileSync(join(evidence!, "capture-conditions.json"), "utf8"));
expect(createHash("sha256").update(frozen).digest("hex")).toBe(conditions.sha256);
expect(browser.version()).toBe(conditions.browser);
expect(conditions.clock).toBe(clock);
await expect(rows(page)).toHaveCount(10);
await expect(page.getByRole("columnheader")).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(page.getByRole("button", { name: "Task view: All tasks", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Collapse all tasks", exact: true })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Search tasks", exact: true })).toHaveValue("");
await expect(page.getByRole("button", { name: "Filter", exact: true })).toBeVisible();
await expect(page.locator('.task-list-sort-button[data-active="true"]')).toHaveCount(0);
await expect(page.getByRole("separator", { name: "Resize sidebar" })).toHaveAttribute("aria-valuenow", "240");
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(page.getByRole("dialog")).toHaveCount(0);
const actual = await page.screenshot({ path: join(evidence!, "reproduction.png"), animations: "disabled", caret: "hide", scale: "css" });
// Decode both PNGs in the same browser. Compare every RGBA channel exactly,
// rather than allowing PNG metadata or compression to determine equality.
const comparison = await page.evaluate(async ({ expected, received }) => {
const decode = async (base64: string) => {
const image = new Image();
image.src = `data:image/png;base64,${base64}`;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = image.width; canvas.height = image.height;
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
return { width: image.width, height: image.height, data: context.getImageData(0, 0, image.width, image.height) };
};
const left = await decode(expected), right = await decode(received);
if (left.width !== right.width || left.height !== right.height) throw new Error("Screenshot dimensions differ");
const canvas = document.createElement("canvas");
canvas.width = left.width; canvas.height = left.height;
const context = canvas.getContext("2d")!, diff = context.createImageData(left.width, left.height);
let changedPixels = 0, maxChannelDelta = 0;
for (let offset = 0; offset < left.data.data.length; offset += 4) {
let changed = false;
for (let channel = 0; channel < 4; channel++) {
const delta = Math.abs(left.data.data[offset + channel] - right.data.data[offset + channel]);
changed ||= delta !== 0;
maxChannelDelta = Math.max(maxChannelDelta, delta);
}
if (changed) changedPixels++;
const gray = Math.round((left.data.data[offset] + left.data.data[offset + 1] + left.data.data[offset + 2]) / 3);
diff.data[offset] = changed ? 255 : gray;
diff.data[offset + 1] = changed ? 0 : gray;
diff.data[offset + 2] = changed ? 0 : gray;
diff.data[offset + 3] = 255;
}
context.putImageData(diff, 0, 0);
return { width: left.width, height: left.height, changedPixels, totalPixels: left.width * left.height, maxChannelDelta, diffBase64: canvas.toDataURL("image/png").split(",")[1] };
}, { expected: frozen.toString("base64"), received: actual.toString("base64") });
const { diffBase64, ...metrics } = comparison;
writeFileSync(join(evidence!, "difference.png"), Buffer.from(diffBase64, "base64"));
writeFileSync(join(evidence!, "visual-comparison.json"), JSON.stringify({
...metrics, changedPixelRatio: metrics.changedPixels / metrics.totalPixels,
tolerancePixels: 0, referenceSha256: conditions.sha256,
reproductionSha256: createHash("sha256").update(actual).digest("hex"),
referenceConditions: "capture-conditions.json", reproductionUrl: page.url(),
conditions: { ...conditions, referenceUrl: undefined, frozenAt: undefined, sha256: undefined },
}, null, 2));
await testInfo.attach("reproduction", { path: join(evidence!, "reproduction.png"), contentType: "image/png" });
await testInfo.attach("difference", { path: join(evidence!, "difference.png"), contentType: "image/png" });
expect(metrics.changedPixels, `Exact pixel comparison: ${JSON.stringify(metrics)}`).toBe(0);
});
test("rendered component and source imports audit proves reuse", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.reload();
await expect(rows(page)).toHaveCount(10);
const names = await componentNames(page);
for (const name of ["DesignPageShell", "SidebarShell", "DesignSidebar", "SidebarCompanyMenu", "SidebarAccountMenu", "TaskListToolbar", "IssueFiltersPopover", "TaskList", "TaskListViewport", "TaskListColumnHeader", "TaskListColumnSettings", "TaskRowGroup", "IssueRow", "CellMarquee", "NewIssueDialog", "CommandPalette"]) expect(names).toContain(name);
expect(names).not.toContain("TaskListPagePreview");
expect(names).not.toContain("TasksReproductionTrial");
const controller = readFileSync("ui/storybook/support/design-proposals/TasksReproductionTrial2.tsx", "utf8");
expect(controller).toMatch(/import\s*\{\s*useTaskListCollection\s*\}\s*from\s*"@\/hooks\/useTaskListCollection"/);
expect(controller).toMatch(/creators:\s*collection\.creators/);
expect(controller).toMatch(/issues=\{collection\.matchingItems\.map/);
expect(controller).not.toMatch(/TaskListPagePreview|TasksReproductionTrial["']|applyIssueFilters|includeTaskListAncestors|matchesTaskListView|createdByAgentId|createdByUserId|toLocaleLowerCase|\.filter\(|style=|<button|<input|<table|<svg|#[0-9a-f]{6}|\[\d+px\]/i);
await page.getByRole("button", { name: "Board view", exact: true }).click();
expect(await componentNames(page)).toContain("KanbanBoard");
const index = await (await page.request.get("/index.json")).json();
expect(index.entries[storyId].title).toBe("Design proposals/AI Generated Test Pages/Tasks reproduction test 2");
expect(errors).toEqual([]);
writeFileSync(join(evidence!, "reuse-audit.json"), JSON.stringify({ storyId, renderedComponents: names, boardComponent: "KanbanBoard", controllerImportsSharedCollection: true, duplicatedCollectionLogicDetected: false, forbiddenCompositionDetected: false, browserErrors: errors }, null, 2));
});
test("read/unread hover, nested guides and all expansion levels", async ({ page }, testInfo) => {
const unread = titles(page).filter({ hasText: /^Improve connector reliability$/ });
const read = titles(page).filter({ hasText: /^Design reconnect and recovery flows$/ });
await expect(unread).toHaveCSS("font-weight", "600");
await expect(read).toHaveCSS("font-weight", "400");
const foreground = await unread.evaluate(element => getComputedStyle(element).color);
expect(await read.evaluate(element => getComputedStyle(element).color)).not.toBe(foreground);
for (const [title, weight] of [[read, "400"], [unread, "600"]] as const) {
const background = () => title.evaluate(element => getComputedStyle(element.closest('[data-slot="task-row"]')!).backgroundColor);
const previous = await background();
const bounds = (await title.boundingBox())!;
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await expect(title).toHaveCSS("color", foreground);
await expect(title).toHaveCSS("font-weight", weight);
expect(await background()).not.toBe(previous);
}
await page.screenshot({ path: testInfo.outputPath("unread-hover.png"), animations: "disabled" });
await expect(page.locator('[data-slot="task-row-tree-guide"]')).toHaveCount(5);
const x = await titles(page).evaluateAll(elements => elements.map(element => element.getBoundingClientRect().x));
expect(x[1] - x[0]).toBe(24);
expect(x[3] - x[1]).toBe(24);
const nested = page.getByRole("button", { name: "Collapse Design reconnect and recovery flows", exact: true });
await expect(nested).toHaveAttribute("aria-expanded", "true");
await nested.click();
await expect(rows(page)).toHaveCount(9);
await page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true }).click();
await expect(rows(page)).toHaveCount(6);
await page.getByRole("button", { name: "Expand all tasks", exact: true }).click();
await expect(rows(page)).toHaveCount(10);
await expect(nested).toHaveAttribute("aria-expanded", "true");
await page.getByRole("button", { name: "Collapse all tasks", exact: true }).click();
await expect(rows(page)).toHaveCount(6);
await page.getByRole("button", { name: "Expand all tasks", exact: true }).click();
await expect(rows(page)).toHaveCount(10);
});
test("sorting, column visibility, order, reset and list/board retention", async ({ page }) => {
await page.getByRole("button", { name: "Task ID: sort ascending", exact: true }).click();
await expect(page.getByRole("columnheader", { name: "Task ID" })).toHaveAttribute("aria-sort", "ascending");
await page.getByRole("button", { name: "Task ID: sort descending", exact: true }).click();
await expect(titles(page).first()).toHaveText("Collect feedback on the refreshed task list");
await page.getByRole("button", { name: "Columns", exact: true }).click();
await page.getByRole("menuitemcheckbox", { name: "Created by", exact: true }).click();
await expect(page.getByRole("menuitemcheckbox", { name: "Created by", exact: true })).toBeChecked();
await page.keyboard.press("Escape");
await expect(page.getByRole("columnheader")).toHaveCount(4);
await page.getByRole("button", { name: "Board view", exact: true }).click();
await expect(page.getByRole("region", { name: "Task board columns", exact: true })).toBeVisible();
await page.getByRole("button", { name: "List view", exact: true }).click();
await expect(page.getByRole("columnheader")).toHaveCount(4);
await expect(titles(page).first()).toHaveText("Collect feedback on the refreshed task list");
await page.getByRole("button", { name: "Created by: sort ascending", exact: true }).press("Alt+ArrowLeft");
await expect(page.getByRole("columnheader")).toHaveText(["Task", "Created by", "Task ID", "Last updated"]);
await page.getByRole("button", { name: "Columns", exact: true }).click();
await page.getByRole("menuitemcheckbox", { name: "Task ID", exact: true }).click();
await page.keyboard.press("Escape");
await expect(page.getByRole("columnheader")).toHaveText(["Task", "Created by", "Last updated"]);
await expect(page.locator('.task-list-sort-button[data-active="true"]')).toHaveCount(0);
await page.getByRole("button", { name: "Created by: sort ascending", exact: true }).click();
await page.getByRole("button", { name: "Columns", exact: true }).click();
await page.getByRole("menuitem", { name: "Reset defaults", exact: true }).click();
await page.keyboard.press("Escape");
await expect(page.getByRole("columnheader")).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(page.locator('.task-list-sort-button[data-active="true"]')).toHaveCount(0);
await expect(titles(page).first()).toHaveText("Improve connector reliability");
});
test("all canonical views and saved view selection survive reload", async ({ page }) => {
await page.getByRole("button", { name: "Task view: All tasks", exact: true }).click();
await expect(page.getByRole("menuitemradio")).toHaveText(["Inbox", "Unread", "Needs attention", "All tasks"]);
await page.keyboard.press("Escape");
for (const [label, count] of [["Inbox", 6], ["Unread", 3], ["Needs attention", 3], ["All tasks", 10]] as const) {
await selectView(page, label);
await expect(rows(page)).toHaveCount(count);
await page.reload();
await expect(page.getByRole("button", { name: `Task view: ${label}`, exact: true })).toBeVisible();
await expect(rows(page)).toHaveCount(count);
}
});
test("human and agent creator filtering preserves identity", async ({ page }) => {
await page.getByRole("button", { name: "Filter", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search projects", exact: true })).toBeVisible();
const creator = page.getByRole("region", { name: "Creator", exact: true });
await creator.getByRole("checkbox", { name: "Scott", exact: true }).click();
await page.keyboard.press("Escape");
await expect(rows(page)).toHaveCount(6);
await expect(titles(page)).toHaveText(["Improve connector reliability", "Chat style tasks v2", "Refine the empty task-list state", "Simplify the first company setup", "Review budget notifications", "Collect feedback on the refreshed task list"]);
await page.getByRole("button", { name: "1", exact: true }).click();
await creator.getByRole("checkbox", { name: "Scott", exact: true }).click();
await creator.getByRole("checkbox", { name: "CodexCoder", exact: true }).click();
await page.keyboard.press("Escape");
await expect(rows(page)).toHaveCount(5);
await expect(page.locator(".design-task-results")).toHaveText("4 matching tasks · Parent tasks included for context");
await expect(titles(page)).toHaveText(["Improve connector reliability", "Define connector health states", "Design reconnect and recovery flows", /Document connector recovery/, "Review failure messages with the team"]);
});
test("collapsed parents reveal children after search, view and filter transitions", async ({ page }) => {
const collapse = () => page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true });
const search = page.getByRole("textbox", { name: "Search tasks", exact: true });
await collapse().click();
await search.fill("expired credential");
await expect(rows(page)).toHaveCount(3);
await expect(titles(page)).toHaveText(["Improve connector reliability", "Design reconnect and recovery flows", /Document connector recovery/]);
await expect(collapse()).toHaveAttribute("aria-expanded", "true");
await expect(page.locator(".design-task-results")).toHaveText("1 matching task · Parent tasks included for context");
await search.fill("");
await collapse().click();
await selectView(page, "Unread");
await expect(rows(page)).toHaveCount(3);
await expect(collapse()).toHaveAttribute("aria-expanded", "true");
await selectView(page, "All tasks");
await collapse().click();
await page.getByRole("button", { name: "Filter", exact: true }).click();
await page.getByRole("checkbox", { name: "In Review", exact: true }).click();
await page.keyboard.press("Escape");
await expect(rows(page)).toHaveCount(3);
await expect(collapse()).toHaveAttribute("aria-expanded", "true");
await expect(page.locator(".design-task-results")).toHaveText("2 matching tasks · Parent tasks included for context");
await page.getByRole("button", { name: "Board view", exact: true }).click();
const board = page.locator(".design-task-board");
await expect(board.getByRole("button")).toHaveCount(2);
await expect(board.getByText("Define connector health states", { exact: true })).toBeVisible();
await expect(board.getByText("Review budget notifications", { exact: true })).toBeVisible();
await expect(board.getByText("Improve connector reliability", { exact: true })).toHaveCount(0);
await expect(page.locator(".design-task-results")).toHaveText("2 matching tasks");
});
test("metadata search covers creator, assignee, project and tags; empty state resets", async ({ page }) => {
const search = page.getByRole("textbox", { name: "Search tasks", exact: true });
for (const [query, count, matching] of [["Scott", 6, 6], ["CodexCoder", 8, 8], ["Design", 6, 5], ["Board UI", 9, 9], ["Design system", 3, 2]] as const) {
await search.fill(query);
await expect(rows(page), `Metadata query ${query}`).toHaveCount(count);
await expect(page.locator(".design-task-results")).toContainText(`${matching} matching tasks`);
}
await expect(titles(page)).toHaveText(["Improve connector reliability", "Define connector health states", "Review budget notifications"]);
await search.fill("no such task");
await expect(page.getByText("No tasks match this view", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear filters", exact: true }).click();
await expect(search).toHaveValue("");
await expect(rows(page)).toHaveCount(10);
});
test("canonical menus, company boundary and sidebar resize persistence", async ({ page }) => {
await page.getByRole("button", { name: "Global search", exact: true }).click();
await expect(page.getByPlaceholder("Search tasks, agents, projects...", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("toolbar", { name: "Task list controls" }).getByRole("button", { name: "New Task", exact: true }).click();
await expect(page.getByRole("dialog", { name: "New task", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Open account menu", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Account", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Invite", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Open Paperclip Storybook organization switcher", exact: true }).click();
await expect(page.getByRole("menuitem", { name: /Research Bureau/ })).toBeVisible();
await page.getByRole("menuitem", { name: /Research Bureau/ }).click();
await expect(rows(page)).toHaveCount(0);
await expect(page.getByText("No tasks in this organization", { exact: true })).toBeVisible();
await expect(page.locator(".design-sidebar-recents a")).toHaveCount(0);
const resize = page.getByRole("separator", { name: "Resize sidebar", exact: true });
await resize.press("ArrowRight");
await expect(resize).toHaveAttribute("aria-valuenow", "256");
await page.reload();
await expect(resize).toHaveAttribute("aria-valuenow", "256");
const bounds = (await resize.boundingBox())!;
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width / 2 + 32, bounds.y + bounds.height / 2);
await page.mouse.up();
await expect(resize).toHaveAttribute("aria-valuenow", "288");
await page.reload();
await expect(resize).toHaveAttribute("aria-valuenow", "288");
});
});
async function openStory(page: Page, id: string) {
await page.clock.setFixedTime(new Date(clock));
await page.goto(`/iframe.html?id=${id}&viewMode=story&globals=theme:dark`);
await expect(page.getByTestId("design-page-shell")).toBeVisible();
await page.evaluate(async () => {
await Promise.all([document.fonts.load('400 14px InterVariable'), document.fonts.load('600 14px InterVariable'), document.fonts.load('400 14px "Paper Mono"')]);
await document.fonts.ready;
});
await page.mouse.move(1439, 899);
}
test("freeze reference before implementation", async ({ page, browser }) => {
test.skip(process.env.TRIAL_2_FREEZE !== "1", "Reference is frozen once, before implementation.");
expect(existsSync(reference), "Never overwrite the frozen reference").toBe(false);
await openStory(page, "tasks-list-page-design--default");
await expect(page.locator('.design-task-list-panel [data-slot="task-row"]')).toHaveCount(10);
await expect(page.getByRole("columnheader")).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(page.getByRole("button", { name: "Task view: All tasks", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Collapse all tasks", exact: true })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Search tasks", exact: true })).toHaveValue("");
await expect(page.getByRole("button", { name: "Filter", exact: true })).toBeVisible();
await expect(page.getByRole("separator", { name: "Resize sidebar" })).toHaveAttribute("aria-valuenow", "240");
await expect(page.locator('.task-list-sort-button[data-active="true"]')).toHaveCount(0);
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(page.getByRole("dialog")).toHaveCount(0);
mkdirSync(evidence!, { recursive: true });
const png = await page.screenshot({ animations: "disabled", caret: "hide", scale: "css" });
writeFileSync(reference, png, { flag: "wx" });
const fonts = await page.evaluate(() => Array.from(document.fonts).filter(font => font.status === "loaded").map(font => ({ family: font.family, weight: font.weight, status: font.status })));
writeFileSync(join(evidence!, "capture-conditions.json"), JSON.stringify({
referenceUrl: page.url(), frozenAt: new Date().toISOString(), clock,
viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1,
browser: browser.version(), platform: process.platform, colorScheme: "dark",
reducedMotion: "reduce", locale: "en-US", timezoneId: "America/Los_Angeles",
fonts, freshIsolatedContext: true, pointer: { x: 1439, y: 899 },
view: "All tasks", layout: "list", sidebarWidth: 240,
columns: ["Task", "Task ID", "Last updated"], sort: null, query: "", filters: [],
hierarchy: "expanded", menus: "closed", sha256: createHash("sha256").update(png).digest("hex"),
}, null, 2), { flag: "wx" });
});
@@ -0,0 +1,412 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { THREAD_PREVIEW_CASES } from "../../ui/src/components/design-sandbox/task-thread-fixtures";
async function openStory(page: Page, id: string) {
await page.goto(`/iframe.html?id=${id}&viewMode=story&globals=theme:dark`);
}
async function resolvedColor(locator: Locator, token: string): Promise<string> {
return locator.evaluate((element, tokenName) => {
const probe = document.createElement("span");
probe.style.color = `var(${tokenName})`;
element.append(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
}, token);
}
test("proposal typography reaches shared buttons, prose and identifiers", async ({ page }) => {
await openStory(page, "design-foundations-typography--type-specification");
const guide = page.getByTestId("typography-foundations-preview");
await expect(guide).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await expect(guide.locator("[data-ramp]")).toHaveCount(9);
await expect(guide.locator('[data-type-sample^="weight-"]')).toHaveCount(2);
await expect(guide.getByRole("button", { name: "Continue", exact: true })).toHaveCSS("font-weight", "400");
for (const selector of ['[data-type-sample="detail-id"]', ".paperclip-markdown code", '.task-row-columns [data-column="taskId"]']) {
const element = guide.locator(selector).first();
await expect(element).toHaveCSS("font-family", /"Paper Mono"/);
}
await expect(guide.locator(".paperclip-markdown a")).toHaveCSS("font-weight", "400");
await expect(guide.locator(".paperclip-markdown strong")).toHaveCSS("font-weight", "600");
await expect(guide.locator('[data-type-sample="message-time"]')).toHaveCSS("font-size", "12px");
await expect(guide.locator('[data-type-sample="message-time"]')).toHaveCSS("line-height", "16px");
const label = (id: string) => guide.locator(`[data-type-sample="${id}"]`).evaluate(element => {
const style = getComputedStyle(element);
return [style.fontFamily, style.fontSize, style.fontWeight, style.lineHeight, style.letterSpacing, style.color, style.textTransform];
});
expect(await label("property-eyebrow")).toEqual(await label("nav-eyebrow"));
});
test("shared sidebar and portaled account menu use the simplified weights and xs metadata", async ({ page }) => {
await openStory(page, "design-sidebar--default");
const organization = page.getByRole("button", { name: /organization switcher/ }).locator("[data-cell-marquee]");
await expect(organization).toHaveCSS("font-weight", "600");
await expect(page.getByRole("button", { name: "New Task", exact: true })).toHaveCSS("font-weight", "400");
await page.getByRole("button", { name: "Open account menu", exact: true }).click();
const header = page.locator("[data-flyout-profile]");
await expect(header).toBeVisible();
await expect(header.locator("p")).toHaveCSS("font-size", "12px");
await expect(header.locator("p")).toHaveCSS("line-height", "16px");
await expect(page.getByRole("link", { name: "Settings", exact: true })).toHaveCSS("font-weight", "400");
});
test("leaving a proposal restores the ordinary foundation typography", async ({ page }) => {
await openStory(page, "design-foundations-typography--type-specification");
await expect(page.getByRole("button", { name: "Continue", exact: true })).toHaveCSS("font-weight", "400");
await openStory(page, "foundations-primitive-matrix--all-primitives");
await expect(page.getByRole("heading", { name: "Primitives and interaction states", exact: true })).toBeVisible();
await expect(page.locator(".design-proposal-theme")).toHaveCount(0);
await expect(page.getByRole("button", { name: "default", exact: true }).first()).toHaveCSS("font-weight", "500");
await expect(page.locator(".font-mono").first()).not.toHaveCSS("font-family", /"Paper Mono"/);
expect(await page.locator("html").evaluate(element => {
const style = getComputedStyle(element);
return [style.getPropertyValue("--font-weight-medium").trim(), style.getPropertyValue("--font-weight-bold").trim(), style.getPropertyValue("--text-micro").trim()];
})).toEqual(["500", "700", "11px"]);
});
test("conversation typography uses the sandbox's complete state catalog and grouped composition", async ({ page }) => {
await openStory(page, "design-foundations-typography--conversation-styles");
const preview = page.getByTestId("sandbox-task-thread");
await expect(preview).toHaveAttribute("data-preview-state", "all");
expect(THREAD_PREVIEW_CASES).toHaveLength(72);
await expect(preview.locator("[data-preview-case]")).toHaveCount(THREAD_PREVIEW_CASES.length);
const selector = preview.getByRole("combobox", { name: "Thread preview state" });
expect(await selector.locator("optgroup option").evaluateAll(options => options.map(option => ({
id: (option as HTMLOptionElement).value, label: option.textContent,
group: option.parentElement?.getAttribute("label"),
}))).then(options => options.sort((a, b) => a.id.localeCompare(b.id)))).toEqual(THREAD_PREVIEW_CASES.map(({ id, label, group }) => ({ id, label, group })).sort((a, b) => a.id.localeCompare(b.id)));
expect(await preview.locator("[data-preview-case]").evaluateAll(elements => elements.map(element => element.getAttribute("data-preview-case"))))
.toEqual(THREAD_PREVIEW_CASES.map(example => example.id));
await selector.selectOption("staging-conversation");
await expect(preview.locator("[data-preview-case]")).toHaveCount(1);
await expect(preview.getByTestId("task-chat-live-run-pill")).toBeVisible();
await expect(preview.getByTestId("task-chat-runner-turn")).toHaveCount(0);
await expect(preview.getByText("Thought through the task", { exact: true })).toBeVisible();
await expect(preview.getByText("Read files", { exact: true })).toBeVisible();
await expect(preview.getByTestId("task-chat-bubble-attached-turn")).toHaveCount(1);
await page.getByText("Typography measurements", { exact: true }).click();
await expect(page.getByTestId("conversation-style-inventory").locator("tbody tr")).not.toHaveCount(0);
await selector.selectOption("runner-running");
await expect(preview.getByTestId("task-chat-runner-turn")).toBeVisible();
await expect(preview.getByTestId("task-chat-live-run-pill")).toHaveCount(0);
});
test("conversation typography keeps permission decisions and replay in the shared preview", async ({ page }) => {
await openStory(page, "design-foundations-typography--conversation-styles");
const preview = page.getByTestId("sandbox-task-thread");
await preview.getByRole("combobox", { name: "Thread preview state" }).selectOption("permission-pending");
const allow = preview.getByRole("button", { name: "Allow once", exact: true });
await allow.click();
await expect(allow).toHaveCount(0);
await preview.getByRole("button", { name: "Reset / replay", exact: true }).click();
await expect(allow).toBeVisible();
await preview.getByRole("button", { name: "Show saved thread", exact: true }).click();
await expect(preview).toHaveAttribute("data-preview-state", "saved");
await expect(preview.locator("[data-preview-case]")).toHaveCount(0);
await expect(preview.getByText(/Saved task data is available in the sandbox/)).toBeVisible();
});
test("conversation type spec measures the shared states and preserves actual sample typography", async ({ page }) => {
await openStory(page, "design-foundations-typography--conversation-type-specification");
const guide = page.getByTestId("conversation-type-spec");
await expect(guide.getByTestId("conversation-type-census")).toContainText("72 states measured");
const preview = guide.getByTestId("conversation-spec-shared-preview");
await expect(preview.locator("[data-preview-case]")).toHaveCount(THREAD_PREVIEW_CASES.length);
await expect(guide.locator("[data-conversation-weight]")).toHaveCount(2);
await expect(guide.locator("[data-conversation-candidate]").first()).toBeVisible();
await guide.getByRole("combobox", { name: "Surface", exact: true }).selectOption("Human message");
const row = guide.locator("[data-conversation-type-role]").first();
const properties = (element: Element) => {
const style = getComputedStyle(element);
return [style.fontFamily, style.fontSize, style.lineHeight, style.fontWeight, style.color, style.letterSpacing];
};
expect(await row.locator("[data-conversation-type-sample]").evaluate(properties))
.toEqual(await preview.getByTestId("task-chat-human-bubble").first().locator("p").first().evaluate(properties));
await row.getByText("Source & recipe", { exact: true }).click();
await expect(row.getByText("ui/src/components/task-chat/TaskChatBubble.tsx", { exact: true })).toBeVisible();
await guide.getByRole("searchbox", { name: "Find a conversation text role" }).fill("no-such-type-recipe");
await expect(guide.locator("[data-conversation-type-role]")).toHaveCount(0);
await expect(guide.getByText("No roles match these filters.", { exact: true })).toBeVisible();
});
test("conversation type spec can measure expanded shared content without deciding requests", async ({ page }) => {
await openStory(page, "design-foundations-typography--conversation-type-specification");
const guide = page.getByTestId("conversation-type-spec");
await expect(guide.getByTestId("conversation-type-census")).toContainText("72 states measured");
const preview = guide.getByTestId("conversation-spec-shared-preview");
await preview.getByRole("combobox", { name: "Thread preview state" }).selectOption("working");
await guide.getByRole("button", { name: "Expand details & measure", exact: true }).click();
await expect(guide.getByTestId("conversation-type-census")).toContainText("1 states measured");
await expect(preview.getByTestId("task-chat-runner-activity-detail").first()).toBeVisible();
await expect(guide.locator('[data-conversation-type-role="Activity detail · code / command"]').first()).toBeVisible();
await preview.getByRole("combobox", { name: "Thread preview state" }).selectOption("permission-pending");
await guide.getByRole("button", { name: "Expand details & measure", exact: true }).click();
await expect(preview.getByRole("button", { name: "Allow once", exact: true })).toBeVisible();
await guide.getByRole("button", { name: "Show all states", exact: true }).click();
await expect(preview.locator("[data-preview-case]")).toHaveCount(THREAD_PREVIEW_CASES.length);
await guide.getByRole("button", { name: "Refresh type inventory", exact: true }).click();
await expect(guide.getByTestId("conversation-type-census")).toContainText("72 states measured");
});
test("proposal conversation uses two sizes with muted progress, contrasting bubbles and preserved shimmer", async ({ page }) => {
await openStory(page, "design-foundations-typography--conversation-type-specification");
const guide = page.getByTestId("conversation-type-spec");
await expect(guide.getByTestId("conversation-type-census")).toContainText("72 states measured");
const preview = guide.getByTestId("conversation-spec-shared-preview");
expect(await guide.locator("[data-conversation-size]").evaluateAll(elements => elements.map(element => element.getAttribute("data-conversation-size")).sort())).toEqual(["12px", "14px"]);
const human = preview.locator('[data-conversation-text="human"] p').first();
const final = preview.locator('[data-conversation-text="final"] p').first();
const progress = preview.locator('[data-conversation-text="progress"] p').first();
for (const element of [human, final, progress]) {
await expect(element).toHaveCSS("font-size", "14px");
await expect(element).toHaveCSS("line-height", "20px");
}
await expect(human).toHaveCSS("color", "rgb(255, 255, 255)");
const foregroundToken = await preview.evaluate(element => getComputedStyle(element).getPropertyValue("--foreground").trim());
await expect(final).toHaveCSS("color", await resolvedColor(preview, "--foreground"));
await expect(progress).toHaveCSS("color", await resolvedColor(preview, "--muted-foreground"));
const pill = preview.locator('[data-preview-case="session-start"]').getByTestId("task-chat-live-run-pill");
await expect(pill.locator(".shimmer-text")).toHaveCSS("font-size", "14px");
await expect(pill.locator(".shimmer-text")).toHaveCSS("--shimmer-base", foregroundToken);
await expect(pill.locator("span").filter({ hasText: /^for / }).last()).toHaveCSS("font-size", "12px");
await guide.getByRole("button", { name: "Expand details & measure", exact: true }).click();
expect(await guide.locator("[data-conversation-size]").evaluateAll(elements => elements.map(element => element.getAttribute("data-conversation-size")).sort())).toEqual(["12px", "14px"]);
await expect(preview.locator('[data-testid="task-chat-runner-activity-detail"] .paperclip-markdown p').first()).toHaveCSS("font-size", "14px");
const streamingFinal = preview.locator('[data-preview-case="streaming-final"] [data-conversation-text="final"] p').last();
await expect(streamingFinal).toHaveCSS("color", await resolvedColor(preview, "--foreground"));
await expect(preview.locator('[data-testid="task-chat-turn-status-header"] [data-conversation-meta]').last()).toHaveCSS("font-size", "12px");
await page.emulateMedia({ reducedMotion: "no-preference" });
await expect(pill.locator(".shimmer-text")).toHaveCSS("animation-name", "shimmer-text-slide");
});
test("conversation playback progresses through one thread and supports pause and replay", async ({ page }) => {
await page.clock.install();
await openStory(page, "design-foundations-typography--conversation-playback");
const playback = page.getByTestId("conversation-playback");
await expect(playback).toHaveAttribute("data-playback-state", "idle");
await expect(page.getByRole("combobox", { name: "Thread preview state" })).toHaveCount(0);
await playback.getByRole("button", { name: "Play conversation", exact: true }).click();
await expect(playback.getByTestId("task-chat-human-bubble")).toHaveCount(1);
await page.clock.runFor(2500);
await expect(playback).toHaveAttribute("data-playback-stage", "1");
await expect(playback.getByTestId("task-chat-live-run-pill")).toBeVisible();
await playback.getByRole("button", { name: "Pause", exact: true }).click();
const paused = await playback.innerText();
await page.clock.runFor(5000);
expect(await playback.innerText()).toBe(paused);
await expect(playback).toHaveAttribute("data-playback-state", "paused");
await playback.getByRole("button", { name: "Resume conversation", exact: true }).click();
await page.clock.runFor(3000);
await expect(playback).toHaveAttribute("data-playback-stage", "3");
await expect(playback.getByTestId("task-chat-human-bubble")).toHaveCount(1);
await expect(playback.getByText(/inspect the component alias/)).toBeVisible();
const runningLabel = playback.locator('[data-conversation-tool-running="true"]').first();
await page.emulateMedia({ reducedMotion: "no-preference" });
await expect(runningLabel).toHaveCSS("animation-name", "shimmer-text-slide");
const foreground = await resolvedColor(playback, "--foreground");
await expect(runningLabel).toHaveCSS("color", foreground);
await page.clock.runFor(7500);
await expect(playback).toHaveAttribute("data-playback-stage", "6");
await expect(playback.locator('[data-conversation-text="final"] .paperclip-markdown').last()).toHaveCSS("color", foreground);
await page.clock.runFor(7500);
await expect(playback).toHaveAttribute("data-playback-stage", "9");
await expect(playback.getByText("Which reconnect policy should I test?", { exact: true })).toBeVisible();
await expect(playback.getByTestId("task-chat-human-bubble")).toHaveCount(2);
await page.clock.runFor(11_000);
await expect(playback).toHaveAttribute("data-playback-stage", "13");
await expect(playback.locator('[data-conversation-tool-running="true"]')).toHaveCount(0);
await page.clock.runFor(5000);
await expect(playback).toHaveAttribute("data-playback-stage", "15");
await expect(playback.locator('[data-conversation-tool-running="true"]').first()).toHaveCSS("animation-name", "shimmer-text-slide");
await page.clock.runFor(10_000);
await expect(playback).toHaveAttribute("data-playback-state", "complete");
await expect(playback.getByTestId("task-chat-agent-bubble")).toHaveCount(2);
await expect(playback.getByText("26 component source files", { exact: true })).toBeVisible();
await playback.getByTestId("task-chat-turn-summary").last().click();
await expect(playback.getByTestId("task-chat-activity-phase-toggle").first()).toBeVisible();
await playback.getByRole("button", { name: "Replay conversation", exact: true }).click();
await expect(playback).toHaveAttribute("data-playback-stage", "0");
await expect(playback.getByTestId("task-chat-agent-bubble")).toHaveCount(0);
});
test("conversation metadata has hover and focus feedback and agent names stay body-sized", async ({ page }) => {
await openStory(page, "design-foundations-typography--conversation-styles");
const preview = page.getByTestId("sandbox-task-thread");
const name = preview.getByTestId("task-chat-agent-identity").first().locator(":scope > span:last-child");
await expect(name).toHaveCSS("font-size", "14px");
await expect(name).toHaveCSS("line-height", "20px");
await expect(name).toHaveCSS("font-weight", "600");
const foreground = await resolvedColor(preview, "--foreground");
await expect(name).toHaveCSS("color", foreground);
const heading = preview.locator('[data-conversation-text="final"] h2').first();
await expect(heading).toHaveCSS("font-size", "14px");
await expect(heading).toHaveCSS("font-weight", "400");
await expect(heading).toHaveCSS("color", foreground);
const activity = preview.getByTestId("task-chat-turn-summary").first();
const label = activity.locator("span").filter({ hasText: /^Activity$/ });
const base = await label.evaluate(element => getComputedStyle(element).color);
await activity.hover();
await expect(label).toHaveCSS("color", foreground);
await expect(activity).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await page.mouse.move(0, 0);
await expect(label).toHaveCSS("color", base);
await activity.focus();
await expect(label).toHaveCSS("color", foreground);
await expect(activity).toHaveCSS("font-size", "12px");
});
test("playback appends complete words with a fade while retaining previous word nodes", async ({ page }) => {
await page.clock.install();
await page.emulateMedia({ reducedMotion: "no-preference" });
await openStory(page, "design-foundations-typography--conversation-playback");
const playback = page.getByTestId("conversation-playback");
await playback.getByRole("button", { name: "Play conversation", exact: true }).click();
await page.clock.runFor(3500);
const prose = playback.locator('[data-conversation-text="progress"] .paperclip-markdown').last();
const words = prose.locator(".conversation-stream-word");
await expect(words.first()).toHaveText("I’ll");
await expect(words.first()).toHaveCSS("animation-name", "conversation-word-in");
await expect(words.first()).toHaveCSS("animation-duration", "0.32s");
const initialCount = await words.count();
const oldWord = await words.first().elementHandle();
await page.clock.runFor(200);
expect(await words.count()).toBeGreaterThan(initialCount);
expect(await oldWord!.evaluate(element => element.isConnected)).toBe(true);
const text = await prose.textContent();
expect("I’ll inspect the component alias first, then check the file list. No code changes are needed.".startsWith(text!)).toBe(true);
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(words.first()).toHaveCSS("animation-name", "none");
});
test("completion fades activity before folding its space and preserves the mounted answer", async ({ page }) => {
await page.clock.install();
await page.emulateMedia({ reducedMotion: "no-preference" });
await openStory(page, "design-foundations-typography--conversation-playback");
const playback = page.getByTestId("conversation-playback");
await playback.getByRole("button", { name: "Play conversation", exact: true }).click();
await page.clock.runFor(15_800);
const turn = playback.getByTestId("task-chat-turn-handoff").first();
const activity = turn.getByTestId("task-chat-handoff-activity");
const answer = turn.getByTestId("task-chat-agent-bubble");
const node = await answer.elementHandle();
const prose = await answer.locator("p").first().elementHandle();
const before = await answer.boundingBox();
const expandedHeight = (await activity.boundingBox())!.height;
await page.clock.runFor(240);
await expect(turn).toHaveAttribute("data-handoff-state", "settling");
await expect(activity).toHaveAttribute("aria-hidden", "true");
await expect(activity).toHaveAttribute("inert", "");
await expect(activity).toHaveCSS("transition-delay", "0s, 0.18s");
await expect(activity).toHaveCSS("transition-duration", "0.18s, 0.48s");
expect(await node!.evaluate(element => element.isConnected)).toBe(true);
expect(await prose!.evaluate(element => element.isConnected)).toBe(true);
// Observe intermediate rendered geometry, not just a class name or endpoint.
await expect.poll(async () => Number(await activity.evaluate(element => getComputedStyle(element).opacity))).toBeLessThan(1);
await expect.poll(async () => (await activity.boundingBox())!.height).toBeLessThan(expandedHeight);
await expect(turn).toHaveAttribute("data-handoff-state", "settled");
expect((await activity.boundingBox())!.height).toBe(0);
expect((await answer.boundingBox())!.y).toBeLessThan(before!.y);
expect(await node!.evaluate(element => element.isConnected)).toBe(true);
expect(await prose!.evaluate(element => element.isConnected)).toBe(true);
await expect(turn.getByTestId("task-chat-turn-summary")).toBeVisible();
await turn.getByTestId("task-chat-turn-summary").click();
await expect(turn.getByTestId("task-chat-activity-phase-toggle").first()).toBeVisible();
// Replay during an exit must discard its retained activity and old timers.
await playback.getByRole("button", { name: "Replay", exact: true }).click();
await page.clock.runFor(15_800);
await page.clock.runFor(240);
await expect(playback.getByTestId("task-chat-turn-handoff").first()).toHaveAttribute("data-handoff-state", "settling");
await playback.getByRole("button", { name: "Replay", exact: true }).click();
await expect(playback.getByTestId("task-chat-turn-handoff")).toHaveCount(1);
await expect(playback.getByTestId("task-chat-agent-bubble")).toHaveCount(0);
});
test("reduced motion settles the final response without an activity exit", async ({ page }) => {
await page.clock.install();
await page.emulateMedia({ reducedMotion: "reduce" });
await openStory(page, "design-foundations-typography--conversation-playback");
const playback = page.getByTestId("conversation-playback");
await playback.getByRole("button", { name: "Play conversation", exact: true }).click();
await page.clock.runFor(16_040);
const turn = playback.getByTestId("task-chat-turn-handoff").first();
await expect(turn).toHaveAttribute("data-handoff-state", "settled");
await expect(turn.getByTestId("task-chat-handoff-activity")).toHaveCSS("transition-property", "none");
await expect(turn.getByTestId("task-chat-turn-summary")).toBeVisible();
await expect(turn.getByTestId("task-chat-live-run-pill")).toHaveCount(0);
});
test("one current item owns shimmer and startup plumbing stays out of the conversation", async ({ page }) => {
await page.clock.install();
await page.emulateMedia({ reducedMotion: "no-preference" });
await openStory(page, "design-foundations-typography--conversation-playback");
const playback = page.getByTestId("conversation-playback");
const owners = playback.locator('.shimmer-text, [data-conversation-tool-running="true"]');
await playback.getByRole("button", { name: "Play conversation", exact: true }).click();
await expect(owners).toHaveCount(1);
await expect(owners).toHaveText("Working");
await expect(playback).not.toContainText(/Preparing the turn|Preparing the transcript|Waiting for transcript|run phase:/i);
await page.clock.runFor(1600);
await expect(owners).toHaveCount(1);
await expect(owners).toHaveText("Thinking");
const working = playback.getByTestId("task-chat-live-run-pill").getByText("Working", { exact: true });
const muted = await resolvedColor(playback, "--muted-foreground");
await expect(working).toHaveCSS("color", muted);
await expect(working).toHaveCSS("animation-name", "none");
await page.clock.runFor(3800);
await expect(owners).toHaveCount(1);
await expect(owners).toHaveAttribute("data-conversation-tool-running", "true");
await expect(owners).toHaveCSS("animation-name", "shimmer-text-slide");
await expect(working).toHaveCSS("color", muted);
await page.clock.runFor(7400);
await expect(playback).toHaveAttribute("data-playback-stage", "6");
await expect(owners).toHaveCount(0);
});
test("mixed-font tool text shares one uninterrupted shimmer loop", async ({ page }) => {
await page.clock.install();
await page.emulateMedia({ reducedMotion: "no-preference" });
await openStory(page, "design-foundations-typography--conversation-playback");
const playback = page.getByTestId("conversation-playback");
await playback.getByRole("button", { name: "Play conversation", exact: true }).click();
await page.clock.runFor(9600);
const line = playback.locator('[data-conversation-tool-running="true"]');
await expect(line).toHaveCount(1);
await expect(line).toContainText("Running a command");
await expect(line.locator("span").first()).toHaveCSS("font-family", /Inter/);
await expect(line.locator("span").last()).toHaveCSS("font-family", /Paper Mono/);
for (const child of await line.locator(":scope > span").all()) {
await expect(child).toHaveCSS("animation-name", "none");
await expect(child).toHaveCSS("background-image", "none");
await expect(child).toHaveCSS("-webkit-text-fill-color", "rgba(0, 0, 0, 0)");
}
const sweep = await line.evaluate(element => {
const animations = element.getAnimations({ subtree: true });
const animation = animations.find(candidate => (candidate as CSSAnimation).animationName === "shimmer-text-slide")!;
const timing = animation.effect!.getTiming();
const keyframes = animation.effect!.getKeyframes();
// Sample the old dead zone: the background must keep moving after 60%.
animation.pause();
animation.currentTime = Number(timing.duration) * 0.65;
const first = getComputedStyle(element).backgroundPosition;
animation.currentTime = Number(timing.duration) * 0.95;
const second = getComputedStyle(element).backgroundPosition;
animation.play();
return { count: animations.length, easing: timing.easing, forever: timing.iterations === Infinity, offsets: keyframes.map(frame => frame.offset), first, second };
});
expect(sweep.count).toBe(1);
expect(sweep.easing).toBe("linear");
expect(sweep.forever).toBe(true);
expect(sweep.offsets).toEqual([0, 1]);
expect(sweep.first).not.toBe(sweep.second);
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(line).toHaveCSS("animation-name", "none");
await expect(line).toHaveCSS("background-image", "none");
await expect(line.locator("span").last()).toHaveCSS("-webkit-text-fill-color", await resolvedColor(playback, "--foreground"));
});
+13
View File
@@ -15,3 +15,16 @@ the same sans-serif text stack without relying on host font packages.
Redistribution note: Inter is redistributed under the SIL Open Font License 1.1.
The bundled WOFF2 files are included unmodified from the upstream v4.1 release.
## Paper Mono
- Upstream project: https://github.com/paper-design/paper-mono
- Version: v1.000
- Release archive: https://github.com/paper-design/paper-mono/releases/download/v1.000/paper-mono-v1.000.zip
- Archive SHA-256: `8642652985dc87bf4cfd07c89942899e611c3c774fc25284c0dd4fe86a993a13`
- Source file: `fonts/webfonts/PaperMono[wght].woff2`, bundled without modification as `PaperMono-Variable.woff2`
- License: SIL Open Font License 1.1; included in `PaperMono-OFL.txt`
The refreshed task-row specimen uses this variable webfont for task identifiers.
It supports upright weights 100–800. Existing uses of the system monospace stack
remain unchanged until their design is reviewed.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2025 The Paper-Mono.Git Project Authors (https://github.com/paper-design/paper-mono.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+1 -1
View File
@@ -260,7 +260,7 @@ function boardRoutes(streamlinedUiEnabled: boolean, combinedInboxTasksEnabled: b
<Route path="company/settings/:settingsRoutePath/*" element={<CompanySettingsPluginPage />} />
<Route path="skills/sources" element={<SkillSources />} />
<Route path="skills/sources/:sourceId" element={<SkillSources />} />
<Route path="skills/studio" element={<SkillStudio />} />
<Route path="skills/studio" element={streamlinedUiEnabled ? <CompanySkills collectionView="authored" /> : <SkillStudio />} />
<Route path="skills/studio/new" element={<SkillStudio />} />
<Route path="skills/studio/:skillId" element={<SkillStudio />} />
<Route path="skills/:skillId/studio" element={<LegacySkillStudioRedirect />} />
+16
View File
@@ -0,0 +1,16 @@
import type { ActivityEvent } from "@paperclipai/shared";
import { ActivityRow, type ActivityRowProps } from "./ActivityRow";
/** Caller-ordered events, with the same row tokens and responsive metadata
* priorities as task summaries. Event destinations and task hover cards remain
* owned by ActivityRow and the shared Link; non-task events never get task IDs. */
export function ActivityList({ events, label = "Recent activity", density = "comfortable", rowClassName, ...directories }: {
events: readonly ActivityEvent[];
label?: string;
density?: "comfortable" | "compact";
rowClassName?: (event: ActivityEvent) => string | undefined;
} & Pick<ActivityRowProps, "agentMap" | "userProfileMap" | "entityNameMap" | "entityTitleMap">) {
return <ul className="task-summary-list activity-list" aria-label={label}>
{events.map(event => <li key={event.id}><ActivityRow {...directories} event={event} appearance="refreshed" density={density} className={rowClassName?.(event)} /></li>)}
</ul>;
}
+53 -10
View File
@@ -8,6 +8,8 @@ import { cn } from "../lib/utils";
import { formatActivityVerb } from "../lib/activity-format";
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
import type { CompanyUserProfile } from "../lib/company-members";
import { useState, type ReactNode } from "react";
import { CellMarquee, HoveredCellContext, useCellMarqueeHover } from "./CellMarquee";
function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
switch (entityType) {
@@ -20,16 +22,18 @@ function entityLink(entityType: string, entityId: string, name?: string | null):
}
}
interface ActivityRowProps {
export interface ActivityRowProps {
event: ActivityEvent;
agentMap: Map<string, Agent>;
userProfileMap?: Map<string, CompanyUserProfile>;
entityNameMap: Map<string, string>;
entityTitleMap?: Map<string, string>;
className?: string;
appearance?: "default" | "refreshed";
density?: "comfortable" | "compact";
}
export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, entityTitleMap, className, appearance = "default", density = "comfortable" }: ActivityRowProps) {
const verb = formatActivityVerb(event.action, event.details, { agentMap, userProfileMap });
const isHeartbeatEvent = event.entityType === "heartbeat_run";
@@ -52,18 +56,24 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
const userProfile = event.actorType === "user" ? userProfileMap?.get(event.actorId) : null;
const actorName = actor?.name ?? (event.actorType === "system" ? "System" : userProfile?.label ?? (event.actorType === "user" ? "Board" : event.actorId || "Unknown"));
const actorAvatarUrl = userProfile?.image ?? null;
const avatar = event.actorType === "agent" ? (
<AgentAvatar agent={actor} name={actorName} size={24} className="@xl:self-center" />
) : (
<Avatar size="sm" aria-hidden="true" className="@xl:self-center">
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt="" />}
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
</Avatar>
);
if (appearance === "refreshed") {
const subject = event.entityType === "issue" ? entityTitle || name || "Task" : [name, entityTitle].filter(Boolean).join(" — ");
return <RefreshedActivityRow event={event} avatar={avatar} actorName={actorName} verb={verb} subject={subject} identifier={event.entityType === "issue" ? name ?? undefined : undefined} link={link} density={density} className={className} />;
}
const inner = (
<div className="space-y-2">
<div className="flex items-start gap-2 @xl:grid @xl:grid-cols-(--dashboard-activity-list-columns) @xl:items-baseline">
{event.actorType === "agent" ? (
<AgentAvatar agent={actor} name={actorName} size={24} className="@xl:self-center" />
) : (
<Avatar size="sm" aria-hidden="true" className="@xl:self-center">
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt="" />}
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
</Avatar>
)}
{avatar}
<div className="flex min-w-0 flex-1 flex-col gap-1 @xl:contents">
<div className="flex min-w-0 items-baseline gap-2 @xl:contents">
<p className="flex h-6 min-w-0 flex-1 items-center gap-1.5">
@@ -115,3 +125,36 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
</div>
);
}
function RefreshedActivityRow({ event, avatar, actorName, verb, subject, identifier, link, density, className }: {
event: ActivityEvent; avatar: ReactNode; actorName: string; verb: string; subject: string;
identifier?: string; link: string | null; density: "comfortable" | "compact"; className?: string;
}) {
const { hoveredCell, onMouseMove, clearHoveredCell } = useCellMarqueeHover();
const [focused, setFocused] = useState(false);
const rowProps = {
"data-slot": "activity-row",
"data-density": density,
className: cn("task-row-refreshed task-summary-row activity-row-refreshed", className),
onMouseMove,
onMouseLeave: () => { clearHoveredCell(); setFocused(false); },
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
};
const content = <>
{avatar}
<span data-marquee-cell="activity" className="min-w-0 flex-1">
<CellMarquee cell="activity" active={focused} slot="task-row-title" tabbable={!link}>
{actorName} {verb}{subject ? ` ${subject}` : ""}
</CellMarquee>
</span>
<span data-slot="task-row-trailing" className="ml-auto flex shrink-0 items-center">
{identifier ? <span data-slot="task-row-identifier" data-marquee-cell="identifier" className="shrink-0 text-right text-muted-foreground"><CellMarquee cell="identifier">{identifier}</CellMarquee></span> : null}
<span data-slot="task-row-timestamp" data-marquee-cell="timestamp" className="shrink-0 text-right text-muted-foreground"><CellMarquee cell="timestamp">{timeAgo(event.createdAt)}</CellMarquee></span>
</span>
</>;
return <HoveredCellContext.Provider value={hoveredCell}>
{link ? <Link to={link} {...rowProps}>{content}</Link> : <div {...rowProps}>{content}</div>}
<div className="activity-row-details"><IssueReferenceActivitySummary event={event} /></div>
</HoveredCellContext.Provider>;
}
+3 -1
View File
@@ -3,6 +3,7 @@ import { useState } from "react";
import { resolveAgentAppearance, type AgentAppearance, type AgentAvatarSize, type CharacterState } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { deriveInitials } from "./Identity";
import { useAvatarInitials } from "./ui/avatar-initials";
export type AvatarAgent = { id?: string; name?: string; appearance?: AgentAppearance | null; avatarUrl?: string | null };
export const avatarSizeClasses: Record<AgentAvatarSize, string> = {
@@ -23,10 +24,11 @@ export function AgentAvatar({ agent, appearance, size = 24, name, label, pose =
const identity = resolveAgentAppearance(appearance ?? agent?.appearance, agent?.id);
const src = agentAvatarUrl(identity, size, 1, pose, muted);
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const initials = useAvatarInitials(deriveInitials(name ?? agent?.name ?? "Agent"));
return (
<span data-slot="agent-avatar" className={cn("relative inline-flex shrink-0 items-center justify-center align-middle", avatarSizeClasses[size], className)}
role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
{failedUrl === src ? <span className="text-xs text-muted-foreground">{deriveInitials(name ?? agent?.name ?? "Agent")}</span> :
{failedUrl === src ? <span className="text-xs text-muted-foreground">{initials}</span> :
<img src={src} srcSet={identity.customAvatarAssetId ? undefined : `${agentAvatarUrl(identity, size, 2, pose, muted)} 2x`} alt="" width={size} height={size}
decoding="async" loading="lazy" className="size-full object-contain" onError={() => setFailedUrl(src)} />}
</span>
+35
View File
@@ -0,0 +1,35 @@
import { List, Network, Plus } from "lucide-react";
import { ToolbarSearch } from "./ToolbarSearch";
import { PageTabBar, type PageTabItem } from "./PageTabBar";
import { Button } from "./ui/button";
export interface AgentsToolbarProps {
query: string;
onQueryChange: (value: string) => void;
tab: string;
items: readonly PageTabItem[];
onTabChange: (value: string) => void;
view: "list" | "org";
onViewChange: (value: "list" | "org") => void;
hideViewToggle?: boolean;
onNewAgent: () => void;
}
/** Shared roster search, status navigation and actions for the page and stories. */
export function AgentsToolbar({ query, onQueryChange, tab, items, onTabChange, view, onViewChange, hideViewToggle, onNewAgent }: AgentsToolbarProps) {
return <div data-canonical-toolbar="agents" role="toolbar" aria-label="Agents controls" className="flex flex-wrap items-center gap-3">
<div className="design-toolbar-controls flex w-full flex-wrap items-center gap-4">
<ToolbarSearch query={query} onQueryChange={onQueryChange} label="Search agents" placeholder="Search agents…" />
{!hideViewToggle && <div className="design-view-toggle" role="group" aria-label="Agent view">
<Button type="button" size="icon-sm" variant={view === "list" ? "secondary" : "ghost"} className="design-toolbar-icon" onClick={() => onViewChange("list")} title="List view" aria-label="List view" aria-pressed={view === "list"}><List aria-hidden /></Button>
<Button type="button" size="icon-sm" variant={view === "org" ? "secondary" : "ghost"} className="design-toolbar-icon" onClick={() => onViewChange("org")} title="Org chart view" aria-label="Org chart view" aria-pressed={view === "org"}><Network aria-hidden /></Button>
</div>}
<div className="design-toolbar-actions">
<Button size="sm" variant="outline" data-toolbar-primary onClick={onNewAgent}><Plus aria-hidden />New Agent</Button>
</div>
</div>
<div className="collection-toolbar-tabs">
<PageTabBar items={items} value={tab} onValueChange={onTabChange} align="start" />
</div>
</div>;
}
+149
View File
@@ -0,0 +1,149 @@
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type MouseEvent as ReactMouseEvent } from "react";
import { cn } from "../lib/utils";
/** Row overlays keep navigation intact; the row reports the cell under the pointer. */
export const HoveredCellContext = createContext<string | null>(null);
/** Shared hit testing for row overlays and ordinary links. Only the cell under
* the pointer reveals, even when its navigation target covers the whole row. */
export function useCellMarqueeHover() {
const [hoveredCell, setHoveredCell] = useState<string | null>(null);
const onMouseMove = (event: ReactMouseEvent<HTMLElement>) => {
const cell = Array.from(event.currentTarget.querySelectorAll<HTMLElement>("[data-marquee-cell]")).find(element => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && event.clientX >= rect.left && event.clientX < rect.right;
});
setHoveredCell(cell?.dataset.marqueeCell ?? null);
};
return { hoveredCell, onMouseMove, clearHoveredCell: () => setHoveredCell(null) };
}
export function CellMarquee({ children, cell, active = false, className, slot = "cell-marquee", contentSlot = "cell-marquee-content", tabbable = true, interactWithParent = false, resetKey }: {
children: ReactNode;
cell: string;
active?: boolean;
className?: string;
slot?: string;
contentSlot?: string;
tabbable?: boolean;
/** Labels inside links/buttons reuse that control's pointer and keyboard target. */
interactWithParent?: boolean;
/** Reset when a parent menu changes state, without replaying on restored focus. */
resetKey?: string | number | boolean;
}) {
const hoveredCell = useContext(HoveredCellContext);
const viewportRef = useRef<HTMLSpanElement>(null);
const contentRef = useRef<HTMLSpanElement>(null);
const previousResetKey = useRef(resetKey);
const ignoreRestoredFocus = useRef(false);
const [overflow, setOverflow] = useState(0);
const [focused, setFocused] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [revealComplete, setRevealComplete] = useState(false);
const [parentHovered, setParentHovered] = useState(false);
const [parentFocused, setParentFocused] = useState(false);
const hovered = hoveredCell === cell || parentHovered;
const activated = hovered || ((active || focused || parentFocused) && (hoveredCell === null || hovered));
const revealing = overflow > 0 && activated && !dismissed;
useLayoutEffect(() => {
if (previousResetKey.current === resetKey) return;
previousResetKey.current = resetKey;
const owner = viewportRef.current?.closest<HTMLElement>('a, button, [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]');
// Menus restore focus to their trigger on dismissal. That is not a new
// request to reveal its label; resume on pointer interaction or a new focus.
ignoreRestoredFocus.current = true;
setParentHovered(owner?.matches(":hover") ?? false);
setFocused(false);
setParentFocused(false);
}, [resetKey]);
useLayoutEffect(() => {
const viewport = viewportRef.current;
const content = contentRef.current;
if (!viewport || !content) return;
const measure = () => {
const width = viewport.getBoundingClientRect().width;
// scrollWidth/clientWidth round to whole pixels even when text-overflow
// has already clipped a fractional glyph. Measure the contents precisely.
const range = content.ownerDocument.createRange();
range.selectNodeContents(content);
setOverflow(width > 0 ? Math.max(0, range.getBoundingClientRect().width - width) : 0);
};
measure();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
observer?.observe(viewport);
observer?.observe(content);
document.fonts?.addEventListener("loadingdone", measure);
return () => {
observer?.disconnect();
document.fonts?.removeEventListener("loadingdone", measure);
};
}, [children, className]);
useEffect(() => {
const viewport = viewportRef.current;
if (!interactWithParent || !viewport) return;
const control = viewport.closest<HTMLElement>('a, button, [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]');
const owner = control ?? viewport.closest<HTMLElement>("[data-marquee-surface]") ?? viewport;
const move = (event: MouseEvent) => {
ignoreRestoredFocus.current = false;
if (control) { setParentHovered(true); return; }
const rect = viewport.getBoundingClientRect();
setParentHovered(event.clientX >= rect.left && event.clientX < rect.right && event.clientY >= rect.top && event.clientY < rect.bottom);
};
const leave = () => setParentHovered(false);
const focus = () => { if (!ignoreRestoredFocus.current) setParentFocused(true); };
const blur = (event: FocusEvent) => {
if (!(event.relatedTarget instanceof Node && owner.contains(event.relatedTarget))) {
ignoreRestoredFocus.current = false;
setParentFocused(false);
}
};
const key = (event: KeyboardEvent) => { if (event.key === "Escape") setDismissed(true); };
owner.addEventListener("mouseenter", move);
owner.addEventListener("mousemove", move);
owner.addEventListener("mouseleave", leave);
owner.addEventListener("focus", focus, true);
owner.addEventListener("blur", blur, true);
owner.addEventListener("keydown", key);
return () => {
owner.removeEventListener("mouseenter", move);
owner.removeEventListener("mousemove", move);
owner.removeEventListener("mouseleave", leave);
owner.removeEventListener("focus", focus, true);
owner.removeEventListener("blur", blur, true);
owner.removeEventListener("keydown", key);
};
}, [interactWithParent]);
useEffect(() => setDismissed(false), [hovered, active, focused, parentFocused]);
useEffect(() => setRevealComplete(false), [revealing, overflow]);
return (
<span
ref={viewportRef}
data-slot={slot}
data-cell-marquee
data-truncated={overflow > 0 ? "true" : undefined}
data-revealing={revealing ? "true" : undefined}
data-reveal-complete={revealing && revealComplete ? "true" : undefined}
className={cn("cell-marquee", className)}
style={{ "--cell-marquee-overflow": overflow } as CSSProperties}
tabIndex={tabbable && overflow > 0 ? 0 : undefined}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onKeyDown={event => { if (event.key === "Escape") setDismissed(true); }}
>
<span className="cell-marquee-viewport">
<span
ref={contentRef}
data-slot={contentSlot}
className="cell-marquee-content"
onTransitionRun={() => setRevealComplete(false)}
onTransitionEnd={event => { if (event.propertyName === "transform") setRevealComplete(true); }}
onTransitionCancel={() => setRevealComplete(false)}
>{children}</span>
</span>
</span>
);
}
+1
View File
@@ -22,6 +22,7 @@ export interface CollectionToolbarProps {
* State, queries, and control behavior stay with the consuming surface. Keeping
* this component slot-based lets Inbox, Tasks, routine runs, and scoped task
* lists share geometry without coupling their data models.
* Canonical proposal composition reference: Components / Toolbars / Collection.
*/
export function CollectionToolbar({
context,
+11 -6
View File
@@ -13,6 +13,7 @@ import {
type AttentionSortOrder,
} from "../lib/attention";
import { cn } from "../lib/utils";
import { ToolbarSearch } from "./ToolbarSearch";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
@@ -25,6 +26,8 @@ const SEVERITY_LABELS: Record<string, string> = {
};
interface DecisionsToolbarProps {
query: string;
onQueryChange: (query: string) => void;
/** Number of decisions currently shown, for the count pill. */
visibleCount: number;
filterOptions: ReturnType<typeof buildAttentionFilterOptions>;
@@ -41,8 +44,11 @@ interface DecisionsToolbarProps {
* desk (WhatNeedsMe) and the per-queue page so both surfaces expose an identical
* control set. All state lives in the parent; this is presentation
* plus the filter popover only.
* Canonical proposal reference: Components / Toolbars / Decisions.
*/
export function DecisionsToolbar({
query,
onQueryChange,
visibleCount,
filterOptions,
filters,
@@ -54,12 +60,8 @@ export function DecisionsToolbar({
}: DecisionsToolbarProps) {
const activeFilterCount = countActiveAttentionFilters(filters);
return (
<div className="flex items-center gap-2">
{visibleCount > 0 && (
<span className="text-sm text-muted-foreground">
{visibleCount} {visibleCount === 1 ? "decision" : "decisions"}
</span>
)}
<div data-canonical-toolbar="decisions" role="toolbar" aria-label="Decisions controls" className="flex flex-wrap items-center gap-2">
<ToolbarSearch query={query} onQueryChange={onQueryChange} label="Search decisions" placeholder="Search decisions…" />
{/* Filter */}
<Popover>
<PopoverTrigger asChild>
@@ -69,6 +71,7 @@ export function DecisionsToolbar({
size="icon"
className={cn("h-8 w-8 shrink-0", activeFilterCount > 0 && "bg-accent")}
title="Filter"
data-active={activeFilterCount > 0}
aria-label="Filter"
>
<ListFilter className="h-3.5 w-3.5" />
@@ -87,6 +90,7 @@ export function DecisionsToolbar({
size="icon"
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
title="Group"
data-active={groupBy !== "none"}
aria-label="Group"
>
<Layers className="h-3.5 w-3.5" />
@@ -144,6 +148,7 @@ export function DecisionsToolbar({
</div>
</PopoverContent>
</Popover>
<span data-toolbar-count className="w-full text-xs text-muted-foreground" aria-live="polite">{visibleCount} {visibleCount === 1 ? "decision" : "decisions"}</span>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useCallback, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
import { usePaneWidthReset } from "@/hooks/usePaneWidthReset";
function readWidth(storageKey: string): number | null {
try {
const stored = window.localStorage.getItem(storageKey);
const value = stored === null ? NaN : Number(stored);
return Number.isFinite(value) && value > 0 ? value : null;
} catch {
return null;
}
}
/** Geometry and resize behavior for the proposed shell's contextual pane. */
export function DesignPageAside({ children, title, maximized, storageKey }: {
children: ReactNode;
title: string;
maximized: boolean;
storageKey: string;
}) {
const asideRef = useRef<HTMLElement>(null);
const [width, setWidth] = useState<number | null>(() => readWidth(storageKey));
const [bounds, setBounds] = useState({ min: 0, max: 0, step: 0 });
const [actualWidth, setActualWidth] = useState(0);
const [dragging, setDragging] = useState(false);
const { resetting, startReset, cancelReset, onResetTransitionEnd } = usePaneWidthReset();
const dragRef = useRef<{ pointerId: number; startX: number; startWidth: number } | null>(null);
const widthRef = useRef(width);
const persistWidth = useCallback((next: number | null) => {
try {
if (next === null) window.localStorage.removeItem(storageKey);
else window.localStorage.setItem(storageKey, String(next));
} catch {
// Resizing still works when browser storage is unavailable.
}
}, [storageKey]);
useLayoutEffect(() => {
cancelReset();
const stored = readWidth(storageKey);
widthRef.current = stored;
setWidth(stored);
}, [storageKey, cancelReset]);
useLayoutEffect(() => { cancelReset(); }, [maximized, cancelReset]);
useLayoutEffect(() => {
const aside = asideRef.current;
const shell = aside?.parentElement;
const sidebar = shell?.querySelector<HTMLElement>(":scope > .design-page-navigation");
if (!aside || !shell || !sidebar) return;
const measure = () => {
const style = getComputedStyle(aside);
const min = parseFloat(style.getPropertyValue("--design-page-aside-min-width"));
const mainMin = parseFloat(style.getPropertyValue("--design-page-main-min-width"));
const step = parseFloat(style.getPropertyValue("--design-page-aside-resize-step"));
const max = Math.max(min, shell.clientWidth - sidebar.getBoundingClientRect().width - mainMin);
setBounds(previous => previous.min === min && previous.max === max && previous.step === step ? previous : { min, max, step });
setActualWidth(Math.round(aside.getBoundingClientRect().width));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(shell);
observer.observe(sidebar);
observer.observe(aside);
return () => observer.disconnect();
}, []);
const clamp = (next: number) => Math.round(Math.min(bounds.max, Math.max(bounds.min, next)));
const updateWidth = (next: number | null) => {
widthRef.current = next;
setWidth(next);
};
const finishResize = () => {
if (!dragRef.current) return;
dragRef.current = null;
setDragging(false);
persistWidth(widthRef.current);
};
const startResize = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
event.preventDefault();
const startWidth = asideRef.current!.getBoundingClientRect().width;
cancelReset();
if (resetting) updateWidth(startWidth);
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startWidth };
setDragging(true);
};
const moveResize = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || event.pointerId !== drag.pointerId) return;
updateWidth(clamp(drag.startWidth + drag.startX - event.clientX));
};
const resetWidth = () => {
startReset();
updateWidth(null);
persistWidth(null);
};
const resizeWithKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
// Read the current geometry so consecutive key presses do not wait for ResizeObserver.
const currentWidth = asideRef.current!.getBoundingClientRect().width;
const next = event.key === "ArrowLeft" ? currentWidth + bounds.step
: event.key === "ArrowRight" ? currentWidth - bounds.step
: event.key === "Home" ? bounds.min
: event.key === "End" ? bounds.max : null;
if (next === null) return;
event.preventDefault();
cancelReset();
const clamped = clamp(next);
updateWidth(clamped);
persistWidth(clamped);
};
return <aside ref={asideRef} className="design-page-aside" aria-label={title}
data-width-resetting={resetting && !maximized && !dragging || undefined} onTransitionEnd={onResetTransitionEnd} style={{
"--design-page-aside-user-width": width === null ? undefined : `${width}px`,
"--design-page-aside-available-width": bounds.max ? `${bounds.max}px` : undefined,
} as CSSProperties}>
{!maximized && <div
role="separator" aria-label="Resize right sidebar" aria-orientation="vertical"
aria-controls="design-page-aside-content" aria-valuemin={bounds.min} aria-valuemax={bounds.max} aria-valuenow={actualWidth}
tabIndex={0} data-dragging={dragging ? "" : undefined}
className="pane-resize-handle design-page-aside-resize group absolute inset-y-0 z-20 cursor-col-resize touch-none outline-none"
onPointerDown={startResize} onPointerMove={moveResize} onPointerUp={finishResize}
onPointerCancel={finishResize} onLostPointerCapture={finishResize}
onDoubleClick={resetWidth} onKeyDown={resizeWithKeyboard}
>
<div className={`mx-auto h-full w-0.5 transition-colors ${dragging ? "bg-ring" : "bg-transparent group-hover:bg-ring group-focus-visible:bg-ring"}`} />
</div>}
{children}
</aside>;
}
+58
View File
@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import { SidebarShell } from "./SidebarShell";
import { DesignPageAside } from "./DesignPageAside";
import { useScrollbarWhileScrolling } from "./side-panel";
import { FlyoutAppearanceProvider } from "./ui/flyout";
export type DesignPageShellProps = {
sidebar: ReactNode;
title: string;
children: ReactNode;
sidebarStorageKey?: string;
headerActions?: ReactNode;
/** Omit for the two-pane layout. Content scrolls independently of the main pane. */
rightSidebar?: ReactNode;
rightSidebarTitle?: string;
rightSidebarHeader?: ReactNode;
rightSidebarControls?: ReactNode;
rightSidebarMaximized?: boolean;
rightSidebarStorageKey?: string;
};
/** Shared proposed desktop shell. Pages supply content; the shell owns pane geometry and scrolling. */
export function DesignPageShell({
sidebar,
title,
children,
sidebarStorageKey = "paperclip.design-proposal.sidebar-width",
headerActions,
rightSidebar,
rightSidebarTitle = "Details",
rightSidebarHeader,
rightSidebarControls,
rightSidebarMaximized = false,
rightSidebarStorageKey = `${sidebarStorageKey}.right`,
}: DesignPageShellProps) {
const handleMainScroll = useScrollbarWhileScrolling<HTMLElement>();
const handleAsideScroll = useScrollbarWhileScrolling();
const hasRightSidebar = rightSidebar != null && rightSidebar !== false;
const maximized = hasRightSidebar && rightSidebarMaximized;
return (
<FlyoutAppearanceProvider><div className="task-row-design-preview design-page-shell" data-testid="design-page-shell" data-aside-maximized={maximized || undefined}>
<a href={maximized ? "#design-page-aside-content" : "#design-page-content"} className="design-page-skip-link">Skip to main content</a>
<SidebarShell open resizable storageKey={sidebarStorageKey} className="design-page-navigation">{sidebar}</SidebarShell>
<div className="design-page-main">
<header className="design-page-header"><h1>{title}</h1>{headerActions && <div className="design-page-header-actions">{headerActions}</div>}</header>
<main id="design-page-content" className="design-page-content scrollbar-while-scrolling" onScroll={handleMainScroll} tabIndex={-1}>{children}</main>
</div>
{hasRightSidebar && <DesignPageAside title={rightSidebarTitle} maximized={maximized} storageKey={rightSidebarStorageKey}>
<header className={`design-page-header${rightSidebarHeader ? " design-page-aside-header" : ""}`}>
<h2 className={rightSidebarHeader ? "sr-only" : undefined}>{rightSidebarTitle}</h2>
{rightSidebarHeader && <div className="design-page-aside-tabs">{rightSidebarHeader}</div>}
{rightSidebarControls && <div className="design-page-aside-controls">{rightSidebarControls}</div>}
</header>
<div id="design-page-aside-content" className="design-page-content scrollbar-while-scrolling" onScroll={handleAsideScroll} role="region" aria-label={`${rightSidebarTitle} content`} tabIndex={0}>{rightSidebar}</div>
</DesignPageAside>}
</div></FlyoutAppearanceProvider>
);
}
@@ -0,0 +1,14 @@
import { useAccountIdentity } from "@/api/companies-query";
import { useCompany } from "@/context/CompanyContext";
import { useDialogActions } from "@/context/DialogContext";
import { useRecentTasks } from "@/hooks/useRecentTasks";
import { DesignSidebar } from "./DesignSidebar";
/** Data adapter only; the visual subject is Components / Sidebar. */
export function DesignSandboxSidebar() {
const { selectedCompanyId } = useCompany();
const { userId } = useAccountIdentity();
const { openNewIssue } = useDialogActions();
const { entries } = useRecentTasks({ companyId: selectedCompanyId, userId });
return <DesignSidebar recentTasks={entries} onNewTask={() => openNewIssue()} />;
}
+59
View File
@@ -0,0 +1,59 @@
import type { Issue } from "@paperclipai/shared";
import { Boxes, CircleCheck, CirclePlus, FolderOpen, History, LayoutDashboard, MessageCircle, Package, Repeat, Search, Unplug, Users } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { SidebarAccountMenu } from "./SidebarAccountMenu";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
import { SidebarNavExpandedProvider, SidebarNavItem } from "./SidebarNavItem";
import { SidebarSection } from "./SidebarSection";
import { TruncatedTextMarqueeProvider } from "./TruncatedText";
import { Button } from "./ui/button";
import { FlyoutAppearanceProvider } from "./ui/flyout";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { useScrollbarWhileScrolling } from "./side-panel/use-scrollbar-while-scrolling";
export type DesignSidebarRecentTask = Pick<Issue, "id" | "companyId" | "identifier" | "title">;
/** Shared staged sidebar. The caller supplies company-scoped recent tasks and actions. */
export function DesignSidebar({ onNewTask, recentTasks = [] }: { onNewTask: () => void; recentTasks?: readonly DesignSidebarRecentTask[] }) {
const { selectedCompany } = useCompany();
const root = `/${selectedCompany?.issuePrefix ?? "PAP"}`;
const companyRecentTasks = recentTasks.filter(task => task.companyId === selectedCompany?.id);
const handleRecentScroll = useScrollbarWhileScrolling();
return (
<FlyoutAppearanceProvider><TruncatedTextMarqueeProvider><SidebarNavExpandedProvider>
<div className="design-sidebar">
<div className="design-sidebar-brand">
<SidebarCompanyMenu showChevron={false} />
<Tooltip><TooltipTrigger asChild><Button variant="ghost" size="icon-sm" className="design-sidebar-search" aria-label="Global search" onClick={() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true, cancelable: true }))}><Search aria-hidden /></Button></TooltipTrigger><TooltipContent side="right">Search tasks, agents, projects</TooltipContent></Tooltip>
</div>
<nav className="design-sidebar-navigation" aria-label="Main navigation">
<div className="design-sidebar-primary">
<Button variant="ghost" className="design-sidebar-new-task" onClick={onNewTask}><CirclePlus aria-hidden />New Task</Button>
<SidebarNavItem className="design-sidebar-item" to={`${root}/dashboard`} label="Dashboard" icon={LayoutDashboard} />
<SidebarNavItem className="design-sidebar-item" to={`${root}/chats`} label="Chat" icon={MessageCircle} />
</div>
<SidebarSection label="Work">
<SidebarNavItem className="design-sidebar-item" to={`${root}/issues`} label="Tasks" icon={CircleCheck} />
<SidebarNavItem className="design-sidebar-item" to={`${root}/projects`} label="Projects" icon={FolderOpen} />
<SidebarNavItem className="design-sidebar-item" to={`${root}/routines`} label="Routines" icon={Repeat} />
<SidebarNavItem className="design-sidebar-item" to={`${root}/artifacts`} label="Artifacts" icon={Package} />
</SidebarSection>
<SidebarSection label="Org">
<SidebarNavItem className="design-sidebar-item" to={`${root}/agents`} label="Agents" icon={Users} />
<SidebarNavItem className="design-sidebar-item" to={`${root}/skills`} label="Skills" icon={Boxes} />
<SidebarNavItem className="design-sidebar-item" to={`${root}/apps`} label="Connectors" icon={Unplug} />
<SidebarNavItem className="design-sidebar-item" to={`${root}/activity`} label="Audit" icon={History} />
</SidebarSection>
{companyRecentTasks.length > 0 ? <div className="design-sidebar-recents">
<SidebarSection label="Recent Tasks">
<div onScroll={handleRecentScroll} className="design-sidebar-recent-list scrollbar-while-scrolling" role="region" aria-label="Recent tasks" tabIndex={0}>
{companyRecentTasks.map(issue => <SidebarNavItem key={issue.id} className="design-sidebar-item" to={`${root}/issues/${issue.identifier}`} label={issue.title} />)}
</div>
</SidebarSection>
</div> : null}
</nav>
<div className="design-sidebar-account"><SidebarAccountMenu forceExpanded /></div>
</div>
</SidebarNavExpandedProvider></TruncatedTextMarqueeProvider></FlyoutAppearanceProvider>
);
}
@@ -0,0 +1,15 @@
import type { ReactNode } from "react";
import { useHorizontalScrollEdges } from "@/hooks/useHorizontalScrollEdges";
import { cn } from "@/lib/utils";
/** One horizontal scroll owner with non-interactive clipping indicators. */
export function HorizontalScrollViewport({ children, label, contentClassName }: { children: ReactNode; label: string; contentClassName?: string }) {
const { scrollRef, contentRef, edges } = useHorizontalScrollEdges();
return (
<div className="horizontal-scroll-viewport" data-overflow-left={edges.left} data-overflow-right={edges.right}>
<div ref={scrollRef} className="horizontal-scroll-area" role="region" aria-label={label} tabIndex={0}>
<div ref={contentRef} className={cn("horizontal-scroll-content", contentClassName)}>{children}</div>
</div>
</div>
);
}
+2 -1
View File
@@ -177,7 +177,8 @@ vi.mock("./InlineEntitySelector", () => ({
),
}));
vi.mock("./Identity", () => ({
vi.mock("./Identity", async importOriginal => ({
...await importOriginal<typeof import("./Identity")>(),
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
@@ -31,7 +31,10 @@ vi.mock("./MarkdownEditor", () => ({
}));
vi.mock("./InlineEntitySelector", () => ({ InlineEntitySelector: () => null }));
vi.mock("./Identity", () => ({ Identity: ({ name }: { name: string }) => <span>{name}</span> }));
vi.mock("./Identity", async importOriginal => ({
...await importOriginal<typeof import("./Identity")>(),
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
vi.mock("./OutputFeedbackButtons", () => ({ OutputFeedbackButtons: () => null }));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
+64
View File
@@ -0,0 +1,64 @@
import { useId, useState, type ComponentProps, type CSSProperties, type ReactNode } from "react";
import { Bot, Search, User } from "lucide-react";
import type { IssueFiltersPopover } from "./IssueFiltersPopover";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { Input } from "./ui/input";
import { FlyoutHeader } from "./ui/flyout";
import { StatusIcon } from "./StatusIcon";
import { TruncatedText } from "./TruncatedText";
import { issueFilterLabel, issueStatusOrder, searchIssueFilterOptions, toggleIssueFilterValue } from "@/lib/issue-filters";
type PanelProps = Pick<ComponentProps<typeof IssueFiltersPopover>, "state" | "onChange" | "activeFilterCount" | "agents" | "projects" | "labels" | "creators" | "currentUserId"> & { onClear: () => void };
function FilterGroup({ title, searchLabel, query, onQueryChange, children, optionsKey }: { title: string; searchLabel?: string; query?: string; onQueryChange?: (value: string) => void; children: ReactNode; optionsKey: string }) {
const headingId = useId();
return (
<section className="task-filter-group" aria-labelledby={headingId}>
<h3 id={headingId}>{title}</h3>
<div className="task-filter-group-body">
{searchLabel ? <div className="task-filter-search"><Search aria-hidden /><Input aria-label={`Search ${searchLabel}`} placeholder={`Search ${searchLabel}...`} value={query} onChange={event => onQueryChange?.(event.target.value)} /></div> : null}
<div className="task-filter-options" data-filter-options={optionsKey}>{children}</div>
</div>
</section>
);
}
function FilterRow({ label, checked, onToggle, icon }: { label: string; checked: boolean; onToggle: () => void; icon?: ReactNode }) {
return <label className="task-filter-option" data-marquee-surface><Checkbox aria-label={label} checked={checked} onCheckedChange={onToggle} />{icon ? <span className="task-filter-icon" aria-hidden>{icon}</span> : null}<TruncatedText className="task-filter-option-label">{label}</TruncatedText></label>;
}
/** Proposed five-group IA; the collection retains ownership of filter state. */
export function IssueFilterPanel({ state, onChange, activeFilterCount, agents = [], projects = [], labels = [], creators = [], currentUserId, onClear }: PanelProps) {
const [queries, setQueries] = useState({ projects: "", creators: "", assignees: "", labels: "" });
const search = (key: keyof typeof queries) => (value: string) => setQueries(current => ({ ...current, [key]: value }));
const visibleProjects = searchIssueFilterOptions(projects, queries.projects, option => option.name);
const visibleCreators = searchIssueFilterOptions(creators, queries.creators, option => `${option.label} ${option.searchText ?? ""}`);
const assignees = [{ id: "__unassigned", name: "No assignee", icon: null }, ...(currentUserId ? [{ id: "__me", name: "Me", icon: <User /> }] : []), ...agents.map(agent => ({ ...agent, icon: null }))];
const visibleAssignees = searchIssueFilterOptions(assignees, queries.assignees, option => option.name);
const visibleLabels = searchIssueFilterOptions(labels, queries.labels, option => option.name);
return <>
<FlyoutHeader className="task-filters-header"><span>Filters</span>{activeFilterCount ? <Button variant="ghost" onClick={() => { onClear(); setQueries({ projects: "", creators: "", assignees: "", labels: "" }); }}>Clear</Button> : null}</FlyoutHeader>
<div className="task-filters-grid">
<FilterGroup title="Status" optionsKey="statuses">
{issueStatusOrder.map(status => <FilterRow key={status} label={issueFilterLabel(status)} checked={state.statuses.includes(status)} onToggle={() => onChange({ statuses: toggleIssueFilterValue(state.statuses, status) })} icon={<StatusIcon status={status} />} />)}
</FilterGroup>
<FilterGroup title="Project" optionsKey="projects" searchLabel="projects" query={queries.projects} onQueryChange={search("projects")}>
{visibleProjects.map(project => <FilterRow key={project.id} label={project.name} checked={state.projects.includes(project.id)} onToggle={() => onChange({ projects: toggleIssueFilterValue(state.projects, project.id) })} />)}
{!visibleProjects.length ? <p className="task-filter-empty">{queries.projects ? "No projects match." : "No projects available."}</p> : null}
</FilterGroup>
<FilterGroup title="Creator" optionsKey="creators" searchLabel="creators" query={queries.creators} onQueryChange={search("creators")}>
{visibleCreators.map(creator => <FilterRow key={creator.id} label={creator.label} checked={state.creators.includes(creator.id)} onToggle={() => onChange({ creators: toggleIssueFilterValue(state.creators, creator.id) })} icon={creator.kind === "agent" ? <Bot /> : <User />} />)}
{!visibleCreators.length ? <p className="task-filter-empty">{queries.creators ? "No creators match." : "No creators available."}</p> : null}
</FilterGroup>
<FilterGroup title="Assignee" optionsKey="responsible" searchLabel="assignees" query={queries.assignees} onQueryChange={search("assignees")}>
{visibleAssignees.map(assignee => <FilterRow key={assignee.id} label={assignee.name} checked={state.assignees.includes(assignee.id)} onToggle={() => onChange({ assignees: toggleIssueFilterValue(state.assignees, assignee.id) })} icon={assignee.icon} />)}
{!visibleAssignees.length ? <p className="task-filter-empty">No assignees match.</p> : null}
</FilterGroup>
<FilterGroup title="Label" optionsKey="labels" searchLabel="labels" query={queries.labels} onQueryChange={search("labels")}>
{visibleLabels.map(label => <FilterRow key={label.id} label={label.name} checked={state.labels.includes(label.id)} onToggle={() => onChange({ labels: toggleIssueFilterValue(state.labels, label.id) })} icon={<span className="task-filter-label-dot" style={{ "--task-filter-label-color": label.color } as CSSProperties} />} />)}
{!visibleLabels.length ? <p className="task-filter-empty">{queries.labels ? "No labels match." : "No labels available."}</p> : null}
</FilterGroup>
</div>
</>;
}
+10 -5
View File
@@ -26,6 +26,8 @@ import { externalObjectStatusIcon } from "../lib/status-colors";
import { formatAssigneeUserLabel } from "../lib/assignees";
import type { InboxApprovalFilter, InboxCategoryFilter } from "../lib/inbox";
import { cn } from "../lib/utils";
import { IssueFilterPanel } from "./IssueFilterPanel";
import { FlyoutAppearanceProvider } from "./ui/flyout";
type AgentOption = {
id: string;
@@ -127,10 +129,11 @@ export function IssueFiltersPopover({
buttonVariant?: "ghost" | "outline";
iconOnly?: boolean;
creators?: CreatorOption[];
presentation?: "legacy" | "streamlined";
presentation?: "legacy" | "streamlined" | "refreshed";
inboxScopeFilters?: InboxScopeFilters;
}) {
const streamlined = presentation === "streamlined";
const refreshed = presentation === "refreshed";
const [creatorSearch, setCreatorSearch] = useState("");
const [assigneeSearch, setAssigneeSearch] = useState("");
const [projectSearch, setProjectSearch] = useState("");
@@ -181,7 +184,7 @@ export function IssueFiltersPopover({
inboxScopeFilters?.onClear();
};
return (
const popover = (
<Popover>
<PopoverTrigger asChild>
<Button variant={buttonVariant} size={iconOnly ? "icon" : "sm"} className={`text-xs ${iconOnly ? "relative h-8 w-8 shrink-0" : ""} ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`} title={iconOnly ? (activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter") : undefined}>
@@ -201,12 +204,13 @@ export function IssueFiltersPopover({
</Button>
</PopoverTrigger>
<PopoverContent
aria-label="Filters"
align="end"
className={streamlined
className={refreshed ? "task-filters-popover" : streamlined
? "w-(--sz-calc-10) max-h-(--sz-calc-9) overflow-y-auto overscroll-contain p-0"
: "w-(--sz-calc-10) p-0"}
>
<div className="space-y-3 p-3">
{refreshed ? <IssueFilterPanel state={state} onChange={onChange} activeFilterCount={activeFilterCount} agents={agents} projects={projects} labels={labels} creators={creators} currentUserId={currentUserId} onClear={clearFilters} /> : <div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filters</span>
{activeFilterCount > 0 ? (
@@ -532,8 +536,9 @@ export function IssueFiltersPopover({
</div>
</div>
</div>
</div>
</div>}
</PopoverContent>
</Popover>
);
return refreshed ? <FlyoutAppearanceProvider>{popover}</FlyoutAppearanceProvider> : popover;
}
+10 -7
View File
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { StatusGlyph } from "@/components/StatusGlyph";
import { ProjectTile } from "@/components/ProjectTile";
import { useFlyoutAppearance } from "@/components/ui/flyout-appearance";
/* ------------------------------------------------------------------ */
/* Single-flight quicklook store */
@@ -169,18 +170,19 @@ export function IssueQuicklookCard({
linkState?: unknown;
compact?: boolean;
}) {
const appearance = useFlyoutAppearance();
const description = useMemo(() => summarizeIssueDescription(issue.description), [issue.description]);
const projectName = issue.project?.name;
return (
<div className={cn("space-y-2", compact && "space-y-1.5")}>
<div className="flex items-center gap-1">
<div className="flex min-w-0 flex-1 items-center gap-1">
<div className={appearance === "refreshed" ? "task-hover-card" : cn("space-y-2", compact && "space-y-1.5")}>
<div data-slot="task-preview-meta" className="flex items-center gap-1">
<div data-slot="task-preview-identity" className="flex min-w-0 flex-1 items-center gap-1">
<StatusGlyph status={issue.status} size="md" title={statusLabel(issue.status)} />
<span className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
<span data-slot="task-preview-id" className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{projectName ? (
{projectName && appearance !== "refreshed" ? (
<>
<QuicklookSeparator className="shrink-0 text-muted-foreground" />
<span
@@ -194,12 +196,13 @@ export function IssueQuicklookCard({
</>
) : null}
</div>
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">
<span data-slot="task-preview-time" className="shrink-0 text-(length:--text-micro) text-muted-foreground">
{timeAgo(new Date(issue.updatedAt))}
</span>
</div>
<RouterDom.Link
data-slot="task-preview-title"
to={linkTo}
state={linkState ?? withIssueDetailHeaderSeed(null, issue)}
className="block text-sm font-medium leading-snug hover:underline line-clamp-2"
@@ -208,7 +211,7 @@ export function IssueQuicklookCard({
</RouterDom.Link>
{description ? (
<p className="text-xs leading-5 text-muted-foreground [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:4] overflow-hidden">
<p data-slot="task-preview-description" className="text-xs leading-5 text-muted-foreground [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:4] overflow-hidden">
{description}
</p>
) : null}
+4 -2
View File
@@ -131,7 +131,8 @@ vi.mock("../lib/assignees", () => ({
},
}));
vi.mock("./StatusIcon", () => ({
vi.mock("./StatusIcon", async importOriginal => ({
...await importOriginal<typeof import("./StatusIcon")>(),
StatusIcon: ({ status, blockerAttention, className, glyphContainerClassName, size }: { status: string; blockerAttention?: Issue["blockerAttention"]; className?: string; glyphContainerClassName?: string; size?: string }) => (
<span className={className} data-glyph-container-class={glyphContainerClassName} data-testid="status-icon" data-size={size} data-status-icon-state={blockerAttention?.state}>{status}</span>
),
@@ -141,7 +142,8 @@ vi.mock("./PriorityIcon", () => ({
PriorityIcon: ({ priority }: { priority: string }) => <span>{priority}</span>,
}));
vi.mock("./Identity", () => ({
vi.mock("./Identity", async importOriginal => ({
...await importOriginal<typeof import("./Identity")>(),
Identity: ({ name, shape }: { name: string; shape?: string }) => <span data-shape={shape ?? "circle"}>{name}</span>,
}));
+74 -1
View File
@@ -7,16 +7,19 @@ import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueRow } from "./IssueRow";
import { StatusIcon } from "./StatusIcon";
import { TooltipProvider } from "./ui/tooltip";
vi.mock("@/lib/router", () => ({
Link: ({
children,
className,
to,
disableIssueQuicklook: _disableIssueQuicklook,
issuePrefetch,
...props
}: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean; issuePrefetch?: Issue | null }) => (
}: React.ComponentProps<"a"> & { to?: string; disableIssueQuicklook?: boolean; issuePrefetch?: Issue | null }) => (
<a
href={to}
className={className}
data-disable-issue-quicklook={_disableIssueQuicklook ? "true" : undefined}
data-issue-prefetch-id={issuePrefetch?.id}
@@ -95,6 +98,7 @@ describe("IssueRow", () => {
afterEach(() => {
container.remove();
vi.unstubAllGlobals();
});
it("renders the list status glyph at md (16px)", () => {
@@ -185,6 +189,75 @@ describe("IssueRow", () => {
act(() => root.unmount());
});
it("starts a truncated title reveal on link focus and keeps actions independent", async () => {
const bounds = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 200, 16));
const createRange = document.createRange.bind(document);
const rangeBounds = vi.spyOn(document, "createRange").mockImplementation(() => Object.assign(createRange(), {
getBoundingClientRect: () => new DOMRect(0, 0, 400, 16),
}));
vi.stubGlobal("ResizeObserver", class {
observe() {}
unobserve() {}
disconnect() {}
});
const root = createRoot(container);
const title = "Define connector health states, failure recovery, and escalation ownership across teams";
const onExpand = vi.fn();
const onArchive = vi.fn();
const onMarkRead = vi.fn();
act(() => {
root.render(
<TooltipProvider>
<IssueRow
issue={createIssue({ title })}
presentation="task"
appearance="refreshed"
unreadState="visible"
onMarkRead={onMarkRead}
leadingControl={<button type="button" onClick={onExpand}>Expand subtasks</button>}
onArchive={onArchive}
/>
</TooltipProvider>,
);
});
const link = container.querySelector<HTMLAnchorElement>("[data-inbox-issue-link]");
const expand = container.querySelector<HTMLButtonElement>('button:not([aria-label])');
const archive = container.querySelector<HTMLButtonElement>('button[aria-label="Archive"]');
expect(link?.href).toContain("/issues/PAP-1");
expect(link?.contains(expand)).toBe(false);
expect(link?.contains(archive)).toBe(false);
// An inbox callback must not restore the old unread dot or its reserved slot.
expect(container.querySelector('button[aria-label="Mark as read"]')).toBeNull();
expect(container.querySelector('[data-testid="issue-row-unread-slot"]')).toBeNull();
await reactAct(async () => link?.focus());
expect(document.activeElement).toBe(link);
expect(container.querySelector('[data-slot="task-row-title"]')?.getAttribute("data-revealing")).toBe("true");
expect(container.querySelector('[data-slot="task-row-title-text"]')?.textContent).toBe(title);
expect(document.querySelector('[role="tooltip"]')).toBeNull();
act(() => link?.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body })));
expect(container.querySelector('[data-slot="task-row-title"]')?.getAttribute("data-revealing")).toBeNull();
await reactAct(async () => { link?.blur(); link?.focus(); });
expect(container.querySelector('[data-slot="task-row-title"]')?.getAttribute("data-revealing")).toBe("true");
act(() => {
link?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expand?.click();
archive?.click();
});
expect(document.querySelector('[role="tooltip"]')).toBeNull();
expect(container.querySelector('[data-slot="task-row-title"]')?.getAttribute("data-revealing")).toBeNull();
expect(onExpand).toHaveBeenCalledTimes(1);
expect(onArchive).toHaveBeenCalledTimes(1);
act(() => root.unmount());
bounds.mockRestore();
rangeBounds.mockRestore();
});
it("preserves the legacy archive action density", () => {
const root = createRoot(container);
+200 -123
View File
@@ -1,5 +1,5 @@
import { requiresExecutionReconciliation } from "@paperclipai/shared";
import type { ReactNode } from "react";
import { useState, type CSSProperties, type ReactNode } from "react";
import type { ExternalObjectSummary, Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Archive, Flag } from "lucide-react";
@@ -19,13 +19,18 @@ import {
readRecoveryRetryLineage,
type RecoveryLivenessContext,
} from "../lib/recovery-lineage";
import { StatusIcon } from "./StatusIcon";
import { StatusIcon, getStatusIconLabel } from "./StatusIcon";
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TaskRowColumnCells, useTaskListColumns, type TaskRowColumnValues } from "./TaskListColumns";
import { CellMarquee, HoveredCellContext, useCellMarqueeHover } from "./CellMarquee";
export type IssueRowUnreadState = "hidden" | "visible" | "fading";
export type IssueRowPresentation = "legacy" | "task";
export type IssueRowAppearance = "default" | "refreshed";
export type IssueRowDensity = "comfortable" | "compact";
export interface IssueRowProps {
issue: Issue;
@@ -33,6 +38,12 @@ export interface IssueRowProps {
selected?: boolean;
/** Opt-in canonical collection layout. Legacy remains the default until each surface migrates. */
presentation?: IssueRowPresentation;
/** Reviewed in Storybook before adoption; only applies to the canonical task presentation. */
appearance?: IssueRowAppearance;
/** Refreshed task rows share their anatomy at either density. */
density?: IssueRowDensity;
/** Opt-in complete column contract for refreshed task lists. */
columnValues?: TaskRowColumnValues;
/** Interactive disclosure or selection control before the canonical status glyph. */
leadingControl?: ReactNode;
/** Optional status override; defaults to the task's shared StatusIcon. */
@@ -122,6 +133,9 @@ export function IssueRow({
issueLinkState,
selected = false,
presentation = "legacy",
appearance = "default",
density = "comfortable",
columnValues,
leadingControl,
statusSlot,
metadata,
@@ -151,12 +165,15 @@ export function IssueRow({
chevronInGuide = false,
showDivider = false,
}: IssueRowProps) {
const visibleColumns = useTaskListColumns();
const { hoveredCell, onMouseMove: handleCellHover, clearHoveredCell } = useCellMarqueeHover();
const [titleFocused, setTitleFocused] = useState(false);
const issuePathId = issue.identifier ?? issue.id;
const identifier = issue.identifier ?? issue.id.slice(0, 8);
// A row participates in the unread system whenever `unreadState` is supplied.
// Canonical rows overlay this affordance in their shared gutter, while legacy
// inbox rows retain their reserved slot until that presentation is migrated.
const showUnreadSlot = unreadState != null;
// Refreshed task rows communicate unread state through title typography alone.
// Existing presentations retain their unread control and its reserved space.
const showUnreadSlot = unreadState != null && !(presentation === "task" && appearance === "refreshed");
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const unreadDotButton = (
<button
@@ -216,134 +233,194 @@ export function IssueRow({
if (presentation === "task") {
const isUnread = unreadState === "visible" || unreadState === "fading";
return (
<div
onMouseEnter={onMouseEnter}
data-slot="task-row"
data-unread={isUnread ? "true" : undefined}
const refreshed = appearance === "refreshed";
const statusLabel = getStatusIconLabel(issue.status, issue.externalConversationState, issue.blockerAttention);
const statusIcon = <StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />;
const status = statusSlot ?? (refreshed ? (
<Tooltip>
<TooltipTrigger asChild>
<Link
to={createIssueDetailPath(issuePathId)}
state={detailState}
disableIssueQuicklook
issuePrefetch={issue}
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
aria-label={`Open task ${identifier} (${statusLabel})`}
className="task-row-status-link"
data-slot="task-row-status"
>
{statusIcon}
</Link>
</TooltipTrigger>
<TooltipContent className="task-row-status-tooltip">{statusLabel}</TooltipContent>
</Tooltip>
) : statusIcon);
const taskLink = (
<Link
to={createIssueDetailPath(issuePathId)}
state={detailState}
disableIssueQuicklook
issuePrefetch={issue}
data-inbox-issue-link
id={checklistRowId}
aria-current={checklistCurrentStep ? "step" : undefined}
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
onFocus={appearance === "refreshed" ? () => setTitleFocused(true) : undefined}
onBlur={appearance === "refreshed" ? () => setTitleFocused(false) : undefined}
onKeyDown={appearance === "refreshed" ? (event) => {
if (event.key === "Escape") {
clearHoveredCell();
setTitleFocused(false);
}
} : undefined}
className={cn(
"group relative flex min-w-0 items-start gap-2 rounded-lg py-2.5 pr-2 text-sm no-underline text-inherit sm:items-center sm:py-2",
showUnreadSlot ? "pl-4" : "pl-2 sm:pl-4",
"[&_button]:relative [&_button]:z-10",
selected ? "bg-accent/50 hover:bg-accent/50" : "hover:bg-accent/50",
checklistCurrentStep && "bg-primary/5",
className,
"absolute inset-0 rounded-lg no-underline text-inherit focus-visible:z-10 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring",
refreshed && "task-row-link",
)}
>
<Link
to={createIssueDetailPath(issuePathId)}
state={detailState}
disableIssueQuicklook
issuePrefetch={issue}
data-inbox-issue-link
id={checklistRowId}
aria-current={checklistCurrentStep ? "step" : undefined}
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
className="absolute inset-0 rounded-lg no-underline text-inherit focus-visible:z-10 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring"
>
<span className="sr-only">Open {identifier}: {issue.title}</span>
</Link>
{showUnreadSlot ? (
<span
data-testid="issue-row-unread-slot"
className="absolute left-0 top-3 inline-flex h-4 w-4 items-center justify-center sm:top-1/2 sm:-translate-y-1/2"
>
{showUnreadDot ? unreadDotButton : null}
</span>
) : null}
<span data-slot="task-row-leading" className="flex shrink-0 items-start self-stretch gap-1 pt-px sm:items-center sm:pt-0">
{treeGuides > 0
? Array.from({ length: treeGuides }, (_, level) => {
const gapForChevron = chevronInGuide && level === treeGuides - 1;
return (
<span
key={`task-guide-${level}`}
data-slot="task-row-tree-guide"
aria-hidden="true"
className="relative block w-4 shrink-0 self-stretch"
>
<span
data-slot="task-row-tree-connector"
className="absolute -inset-y-3 left-7 w-px bg-background"
>
{gapForChevron ? (
<span className="absolute inset-0 flex flex-col">
<span className="flex-1 bg-border" />
<span className="h-3.5 shrink-0" />
<span className="flex-1 bg-border" />
</span>
) : (
<span className="absolute inset-0 bg-border" />
)}
</span>
</span>
);
})
: null}
{leadingControl}
{statusSlot ?? (
<StatusIcon
status={issue.status} externalConversationState={issue.externalConversationState}
blockerAttention={issue.blockerAttention}
size="md"
className={selectedStatusClass}
/>
<span className="sr-only">Open {identifier}: {issue.title}</span>
</Link>
);
return (
<HoveredCellContext.Provider value={hoveredCell}>
<div
onMouseEnter={onMouseEnter}
onMouseMove={refreshed ? handleCellHover : undefined}
onMouseLeave={refreshed ? () => { clearHoveredCell(); setTitleFocused(false); } : undefined}
role={refreshed && columnValues ? "row" : undefined}
data-slot="task-row"
data-unread={isUnread ? "true" : undefined}
data-appearance={refreshed ? "refreshed" : undefined}
data-density={refreshed ? density : undefined}
data-columns={refreshed && columnValues ? "all" : undefined}
data-status-hidden={refreshed && columnValues && !visibleColumns.includes("task") ? "true" : undefined}
data-title-hidden={refreshed && columnValues && !visibleColumns.includes("task") ? "true" : undefined}
style={refreshed ? { "--task-row-depth": treeGuides } as CSSProperties : undefined}
data-selected={selected ? "true" : undefined}
data-current-step={checklistCurrentStep ? "true" : undefined}
className={cn(
"group relative flex min-w-0 items-start gap-2 rounded-lg py-2.5 pr-2 text-sm no-underline text-inherit sm:items-center sm:py-2",
!refreshed && (showUnreadSlot ? "pl-4" : "pl-2 sm:pl-4"),
"[&_button]:relative [&_button]:z-10",
selected ? "bg-accent/50 hover:bg-accent/50" : "hover:bg-accent/50",
checklistCurrentStep && "bg-primary/5",
refreshed && "task-row-refreshed",
className,
)}
{parkedBlockerIndicator}
</span>
>
{taskLink}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<span data-slot="task-row-title-cluster" className="flex min-w-0 flex-1 items-baseline gap-1.5 sm:items-center">
{showUnreadSlot ? (
<span
data-slot="task-row-title"
className={cn(
"min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none",
isUnread && "font-semibold",
titleClassName,
)}
data-testid="issue-row-unread-slot"
className="absolute left-0 top-3 inline-flex h-4 w-4 items-center justify-center sm:top-1/2 sm:-translate-y-1/2"
>
{issue.title}{titleSuffix}
{showUnreadDot ? unreadDotButton : null}
</span>
{recoveryIndicator}
{mobileTitleMeta ? (
<span className="ml-auto shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground sm:hidden">
{mobileTitleMeta}
</span>
) : null}
<span role={refreshed && columnValues ? "cell" : undefined} data-slot="task-row-leading" data-marquee-cell={refreshed ? "task" : undefined} className="flex shrink-0 items-start self-stretch gap-1 pt-px sm:items-center sm:pt-0">
{treeGuides > 0
? Array.from({ length: treeGuides }, (_, level) => {
const gapForChevron = chevronInGuide && level === treeGuides - 1;
return (
<span
key={`task-guide-${level}`}
data-slot="task-row-tree-guide"
data-chevron-gap={gapForChevron ? "true" : undefined}
aria-hidden="true"
className="relative block w-4 shrink-0 self-stretch"
>
<span
data-slot="task-row-tree-connector"
className="absolute -inset-y-3 left-7 w-px bg-background"
>
{gapForChevron ? (
<span className="absolute inset-0 flex flex-col">
<span data-slot="task-row-tree-line" className="flex-1 bg-border" />
<span data-slot="task-row-tree-gap" className="h-3.5 shrink-0" />
<span data-slot="task-row-tree-line" className="flex-1 bg-border" />
</span>
) : (
<span data-slot="task-row-tree-line" className="absolute inset-0 bg-border" />
)}
</span>
</span>
);
})
: null}
{refreshed ? (
<span data-slot="task-row-leading-control">{leadingControl}</span>
) : leadingControl}
{refreshed && columnValues && !visibleColumns.includes("task") ? null : status}
{parkedBlockerIndicator}
</span>
<span role={refreshed && columnValues ? "cell" : undefined} data-slot="task-row-content" data-marquee-cell={refreshed ? "task" : undefined} className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<span data-slot="task-row-title-cluster" className="flex min-w-0 flex-1 items-baseline gap-1.5 sm:items-center">
{refreshed ? (
<CellMarquee cell="task" active={titleFocused} slot="task-row-title" contentSlot="task-row-title-text" tabbable={false} className={cn("min-w-0", isUnread && "font-semibold", titleClassName)}>
{issue.title}{titleSuffix}
</CellMarquee>
) : <span
data-slot="task-row-title"
className={cn(
"min-w-0 line-clamp-2 text-sm sm:truncate sm:line-clamp-none",
isUnread && "font-semibold",
titleClassName,
)}
>
{issue.title}{titleSuffix}
</span>}
{recoveryIndicator}
{mobileTitleMeta ? (
<span className="ml-auto shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground sm:hidden">
{mobileTitleMeta}
</span>
) : null}
</span>
{checklistDependencyChips ? (
<span className="flex flex-wrap gap-1">{checklistDependencyChips}</span>
) : null}
{mobileMeta ? (
<span className="text-xs text-muted-foreground sm:hidden">{mobileMeta}</span>
) : null}
</span>
{checklistDependencyChips ? (
<span className="flex flex-wrap gap-1">{checklistDependencyChips}</span>
) : null}
{mobileMeta ? (
<span className="text-xs text-muted-foreground sm:hidden">{mobileMeta}</span>
) : null}
</span>
<span
data-slot="task-row-trailing"
className="ml-auto hidden min-w-0 shrink-0 items-center gap-2 sm:flex"
>
{externalObjectSummary ? (
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
) : null}
{metadata ? <span data-slot="task-row-metadata" className="min-w-0">{metadata}</span> : null}
{desktopTrailing}
{actions ? <span data-slot="task-row-actions" className="flex shrink-0 items-center gap-1">{actions}</span> : null}
{onArchive ? <InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} compact /> : null}
{showIdentifier ? (
<span data-slot="task-row-identifier" className="w-20 shrink-0 text-right font-mono text-xs text-muted-foreground">
{identifier}
</span>
) : null}
{trailingMeta ? (
<span data-slot="task-row-timestamp" className="w-24 shrink-0 truncate text-right text-xs text-muted-foreground">
{trailingMeta}
</span>
) : null}
</span>
</div>
<span
data-slot="task-row-trailing"
className="ml-auto hidden min-w-0 shrink-0 items-center gap-2 sm:flex"
>
{refreshed && columnValues ? (
<>
<TaskRowColumnCells identifier={identifier} values={columnValues} />
{actions ? <span data-slot="task-row-actions" className="flex shrink-0 items-center gap-1">{actions}</span> : null}
{onArchive ? <InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} compact /> : null}
</>
) : (
<>
{externalObjectSummary ? (
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
) : null}
{metadata ? <span data-slot="task-row-metadata" data-marquee-cell={refreshed ? "metadata" : undefined} className="min-w-0">{refreshed ? <CellMarquee cell="metadata">{metadata}</CellMarquee> : metadata}</span> : null}
{desktopTrailing}
{actions ? <span data-slot="task-row-actions" className="flex shrink-0 items-center gap-1">{actions}</span> : null}
{onArchive ? <InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} compact /> : null}
{showIdentifier ? (
<span data-slot="task-row-identifier" data-marquee-cell={refreshed ? "identifier" : undefined} className="w-20 shrink-0 text-right font-mono text-xs text-muted-foreground">
{refreshed ? <CellMarquee cell="identifier">{identifier}</CellMarquee> : identifier}
</span>
) : null}
{trailingMeta ? (
<span data-slot="task-row-timestamp" data-marquee-cell={refreshed ? "timestamp" : undefined} className="w-24 shrink-0 truncate text-right text-xs text-muted-foreground">
{refreshed ? <CellMarquee cell="timestamp">{trailingMeta}</CellMarquee> : trailingMeta}
</span>
) : null}
</>
)}
</span>
</div>
</HoveredCellContext.Provider>
);
}
+29 -31
View File
@@ -29,6 +29,7 @@ import { collectSubtreeLiveCounts } from "../lib/liveIssueIds";
import { cn } from "../lib/utils";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { HorizontalScrollViewport } from "./HorizontalScrollViewport";
export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100;
export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const;
@@ -155,6 +156,8 @@ interface KanbanBoardProps {
collapsedStatuses?: string[];
initialVisibleCount?: number;
revealIncrement?: number;
/** Proposed clipping indicators, with a single horizontal scroll owner. */
showOverflowFades?: boolean;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
@@ -391,6 +394,7 @@ export function KanbanBoard({
collapsedStatuses = [],
initialVisibleCount = KANBAN_COLUMN_INITIAL_VISIBLE_LIMIT,
revealIncrement = KANBAN_COLUMN_REVEAL_INCREMENT,
showOverflowFades = false,
onUpdateIssue,
}: KanbanBoardProps) {
const [activeId, setActiveId] = useState<string | null>(null);
@@ -455,6 +459,28 @@ export function KanbanBoard({
// Could be used for visual feedback; keeping simple for now
}
const columns = boardStatuses.map((status) => (
<KanbanColumn
key={status}
status={status}
issues={columnIssues[status] ?? []}
agents={agents}
liveIssueIds={liveIssueIds}
subtreeLiveCounts={subtreeLiveCounts}
compactCards={compactCards}
// Compact mode also collapses empty lanes to labeled rails.
collapsed={collapsedStatusSet.has(status) || (collapsedStatusSet.size > 0 && columnIssues[status].length === 0)}
visibleCount={visibleCountByStatus[status] ?? initialVisibleCount}
revealIncrement={revealIncrement}
onShowMore={() => {
setVisibleState((current) => {
const counts = current.paginationKey === paginationKey ? current.counts : {};
return { paginationKey, counts: { ...counts, [status]: (counts[status] ?? initialVisibleCount) + revealIncrement } };
});
}}
/>
));
return (
<DndContext
sensors={sensors}
@@ -462,37 +488,9 @@ export function KanbanBoard({
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-3 overflow-x-auto pb-4 -mx-2 px-2">
{boardStatuses.map((status) => (
<KanbanColumn
key={status}
status={status}
issues={columnIssues[status] ?? []}
agents={agents}
liveIssueIds={liveIssueIds}
subtreeLiveCounts={subtreeLiveCounts}
compactCards={compactCards}
// Compact mode (any lane explicitly collapsed) also collapses
// empty lanes to the same labeled rail, so an empty In Progress
// reads like the other rails instead of a lone expanded column.
collapsed={collapsedStatusSet.has(status) || (collapsedStatusSet.size > 0 && columnIssues[status].length === 0)}
visibleCount={visibleCountByStatus[status] ?? initialVisibleCount}
revealIncrement={revealIncrement}
onShowMore={() => {
setVisibleState((current) => {
const counts = current.paginationKey === paginationKey ? current.counts : {};
return {
paginationKey,
counts: {
...counts,
[status]: (counts[status] ?? initialVisibleCount) + revealIncrement,
},
};
});
}}
/>
))}
</div>
{showOverflowFades
? <HorizontalScrollViewport label="Task board columns" contentClassName="flex gap-3 pb-4 px-2">{columns}</HorizontalScrollViewport>
: <div className="flex gap-3 overflow-x-auto pb-4 -mx-2 px-2">{columns}</div>}
<DragOverlay>
{activeIssue ? (
<KanbanCard issue={activeIssue} agents={agents} isOverlay compact={compactCards} />
+12 -6
View File
@@ -1,3 +1,5 @@
import { useDesignSandbox } from "@/context/DesignSandboxContext";
import { DesignSandboxSidebar } from "./DesignSandboxSidebar";
import { BoardDialogs } from "./BoardDialogs";
import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext";
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
@@ -113,6 +115,7 @@ function isSkillsStoreRoute(
}
export function Layout() {
const designSandbox = useDesignSandbox();
const {
sidebarOpen,
setSidebarOpen,
@@ -636,18 +639,21 @@ export function Layout() {
) : (
<SidebarShell
open={sidebarOpen}
collapsed={collapsed}
peeking={peeking}
collapsed={designSandbox ? false : collapsed}
peeking={designSandbox ? false : peeking}
resizable
storageKey={designSandbox ? "paperclip.design-proposals.sandbox.sidebar-width" : undefined}
onPanelMouseEnter={handlePanelPointerEnter}
onPanelMouseLeave={handlePanelPointerLeave}
onPanelFocusCapture={collapsed ? handlePanelFocus : undefined}
onPanelBlurCapture={collapsed ? handlePanelBlur : undefined}
>
<div className="flex flex-1 min-h-0">
<Sidebar />
</div>
<SidebarAccountMenu deploymentMode={health?.deploymentMode} />
{designSandbox ? <DesignSandboxSidebar /> : <>
<div className="flex flex-1 min-h-0">
<Sidebar />
</div>
<SidebarAccountMenu deploymentMode={health?.deploymentMode} />
</>}
</SidebarShell>
)}
+35 -2
View File
@@ -6,6 +6,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Layout } from "./Layout";
const mockDesignSandbox = vi.hoisted(() => ({ enabled: false }));
vi.mock("@/context/DesignSandboxContext", () => ({ useDesignSandbox: () => mockDesignSandbox.enabled }));
vi.mock("./DesignSandboxSidebar", () => ({ DesignSandboxSidebar: () => <div>Proposed sidebar</div> }));
const mockHealthApi = vi.hoisted(() => ({
get: vi.fn(),
}));
@@ -308,6 +312,7 @@ describe("Layout", () => {
let container: HTMLDivElement;
beforeEach(() => {
mockDesignSandbox.enabled = false;
container = document.createElement("div");
document.body.appendChild(container);
currentPathname = "/PAP/dashboard";
@@ -515,6 +520,20 @@ describe("Layout", () => {
});
});
it("retains settings navigation and route content beside the proposed sandbox sidebar", async () => {
currentPathname = "/PAP/company/settings/access";
mockDesignSandbox.enabled = true;
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => { root.render(<QueryClientProvider client={queryClient}><Layout /></QueryClientProvider>); });
await flushReact();
expect(container.textContent).toContain("Proposed sidebar");
expect(container.textContent).toContain("Company settings sidebar");
expect(container.textContent).toContain("Outlet content");
expect(container.textContent).not.toContain("Main company nav");
await act(async () => { root.unmount(); });
});
it("replaces the app sidebar with settings navigation on Streamlined settings routes", async () => {
currentPathname = "/PAP/company/settings/access";
mockPluginSlots.slots = [
@@ -869,7 +888,7 @@ describe("Layout", () => {
});
});
it("keeps global navigation beside Skills, Agent, and Routine details", async () => {
it("keeps global navigation beside Agent and Routine details", async () => {
async function renderAt(pathname: string) {
currentPathname = pathname;
const root = createRoot(container);
@@ -886,7 +905,6 @@ describe("Layout", () => {
}
for (const [pathname, sidebarText] of [
["/PAP/skills/studio", "Skills sidebar"],
["/PAP/agents/briefing-analyst/skills", "Agent sidebar briefing-analyst"],
[
"/PAP/agents/briefing-analyst/runs/run-1",
@@ -951,6 +969,21 @@ describe("Layout", () => {
}
});
it("uses the global rail without secondary Skills navigation in the streamlined shell", async () => {
for (const path of ["/PAP/skills", "/PAP/skills?tab=discover", "/PAP/skills/studio", "/PAP/skills/sources"]) {
currentPathname = path;
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => { root.render(<QueryClientProvider client={queryClient}><Layout /></QueryClientProvider>); });
await flushReact();
await flushReact();
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Skills sidebar");
await act(async () => root.unmount());
container.innerHTML = "";
}
});
it("keeps the global rail beside Skills navigation in the legacy shell", async () => {
currentPathname = "/PAP/skills/studio";
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
+21 -18
View File
@@ -1,3 +1,5 @@
import { useDesignSandbox } from "@/context/DesignSandboxContext";
import { DesignSandboxSidebar } from "./DesignSandboxSidebar";
import { BoardDialogs } from "./BoardDialogs";
import { SetupWizardSidebarOutlet } from "./SetupWizard";
import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext";
@@ -79,6 +81,7 @@ const RESERVED_APP_SUBPATHS = new Set([
]);
export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
const designSandbox = useDesignSandbox();
const {
sidebarOpen,
setSidebarOpen,
@@ -171,7 +174,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
[routeSidebarCompanyId, routeSidebarCompanyPrefix],
);
// Most contextual routes replace the global navigation inside the same
// sidebar shell. Skills, Apps, Agent details, and Routine details are the
// sidebar shell. Apps, Agent details, and Routine details are the
// exceptions in Streamlined UI: their local navigation is a second rail
// beside the persistent company nav.
const sharedSecondarySidebar = isCompanySettingsRoute ? (
@@ -210,13 +213,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
<AgentContextualSidebar agentRef={agentId} />
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? (
<SetupWizardSidebarOutlet><RoutineContextualSidebar routineId={routineId} /></SetupWizardSidebarOutlet>
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "skills" ? (
<SkillsContextualSidebar />
) : sharedSecondarySidebar;
const hasSecondarySidebar = secondarySidebar != null;
const keepsPrimarySidebar = streamlinedUiEnabled && hasSecondarySidebar && (
isAgentChatRoute
|| shellRoute.builtInContextualSurface === "skills"
|| shellRoute.builtInContextualSurface === "agent"
|| shellRoute.builtInContextualSurface === "routine"
|| isAppsRoute
@@ -625,29 +625,32 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
) : (
<SidebarShell
open={sidebarOpen}
collapsed={replacesPrimarySidebar ? false : collapsed}
peeking={replacesPrimarySidebar ? false : peeking}
collapsed={designSandbox || replacesPrimarySidebar ? false : collapsed}
peeking={designSandbox || replacesPrimarySidebar ? false : peeking}
resizable
storageKey={designSandbox ? "paperclip.design-proposals.sandbox.sidebar-width" : undefined}
onPanelMouseEnter={replacesPrimarySidebar ? undefined : handlePanelPointerEnter}
onPanelMouseLeave={replacesPrimarySidebar ? undefined : handlePanelPointerLeave}
onPanelFocusCapture={!replacesPrimarySidebar && collapsed ? handlePanelFocus : undefined}
onPanelBlurCapture={!replacesPrimarySidebar && collapsed ? handlePanelBlur : undefined}
>
<div className="flex flex-1 min-h-0">
{replacesPrimarySidebar ? (
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
) : (
<Sidebar>{sidebarSections}</Sidebar>
)}
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
forceExpanded={replacesPrimarySidebar}
/>
{designSandbox ? <DesignSandboxSidebar /> : <>
<div className="flex flex-1 min-h-0">
{replacesPrimarySidebar ? (
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
) : (
<Sidebar>{sidebarSections}</Sidebar>
)}
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
forceExpanded={replacesPrimarySidebar}
/>
</>}
</SidebarShell>
)}
{!isMobile && showsAdjacentSecondarySidebar && !keepsPrimarySidebar ? (
{!isMobile && ((showsAdjacentSecondarySidebar && !keepsPrimarySidebar) || (designSandbox && replacesPrimarySidebar)) ? (
<SecondarySidebar className="w-60 shrink-0">
{secondarySidebar}
</SecondarySidebar>
+4 -1
View File
@@ -1,4 +1,5 @@
import { isValidElement, memo, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { MarkdownWordStreaming, rehypeStreamingWords } from "./MarkdownWordStreaming";
import { isValidElement, memo, useContext, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, ExternalLink, WrapText } from "lucide-react";
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
@@ -795,6 +796,7 @@ function MarkdownBodyImpl({
resolveWorkspaceFileRef,
}: MarkdownBodyProps) {
const { theme } = useTheme();
const streamWords = useContext(MarkdownWordStreaming);
// Read company prefixes non-throwingly: MarkdownBody renders in surfaces that
// may lack a CompanyProvider. A null context (or no companies yet) leaves
// knownPrefixes undefined, which keeps issue auto-linking permissive.
@@ -1035,6 +1037,7 @@ function MarkdownBodyImpl({
>
<Markdown
remarkPlugins={remarkPlugins}
rehypePlugins={streamWords ? [rehypeStreamingWords] : undefined}
components={components}
urlTransform={safeMarkdownUrlTransform}
>
@@ -0,0 +1,26 @@
import { createContext } from "react";
/** Opt-in for synthetic streaming previews; settled/ordinary Markdown stays
* unchanged. New word elements animate once; existing DOM words are retained. */
export const MarkdownWordStreaming = createContext(false);
type WordNode = {
type: string; value?: string; tagName?: string;
properties?: Record<string, unknown>; children?: WordNode[];
};
export function rehypeStreamingWords() {
return (tree: WordNode) => {
const visit = (node: WordNode) => {
// Code renderers consume text directly and own their own syntax markup.
if (!node.children || node.tagName === "pre" || node.tagName === "code") return;
node.children = node.children.flatMap(child => {
if (child.type !== "text" || !child.value) { visit(child); return [child]; }
return (child.value.match(/\s+|\S+/gu) ?? []).map(value => /\S/u.test(value) ? {
type: "element", tagName: "span", properties: { className: ["conversation-stream-word"] },
children: [{ type: "text", value }],
} : { type: "text", value });
});
};
visit(tree);
};
}
+21 -16
View File
@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "@/lib/router";
import { Card } from "./ui/card";
interface MetricCardProps {
icon: LucideIcon;
@@ -9,29 +10,33 @@ interface MetricCardProps {
description?: ReactNode;
to?: string;
onClick?: () => void;
/** Use the shared bordered Card surface instead of an unframed metric. */
appearance?: "plain" | "card";
}
export function MetricCard({ icon: Icon, value, label, description, to, onClick }: MetricCardProps) {
export function MetricCard({ icon: Icon, value, label, description, to, onClick, appearance = "plain" }: MetricCardProps) {
const isClickable = !!(to || onClick);
const inner = (
<div className={`h-full px-4 py-4 sm:px-5 sm:py-5 rounded-lg transition-colors${isClickable ? " hover:bg-accent/50 cursor-pointer" : ""}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-2xl sm:text-3xl font-semibold tracking-tight tabular-nums">
{value}
</p>
<p className="text-xs sm:text-sm font-medium text-muted-foreground mt-1">
{label}
</p>
{description && (
<div className="text-xs text-muted-foreground/70 mt-1.5 hidden sm:block">{description}</div>
)}
</div>
<Icon className="h-4 w-4 text-muted-foreground/50 shrink-0 mt-1.5" />
const content = (
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className={`text-2xl sm:text-3xl font-semibold tracking-tight tabular-nums${appearance === "card" ? " text-foreground" : ""}`}>
{value}
</p>
<p className="text-xs sm:text-sm font-medium text-muted-foreground mt-1">
{label}
</p>
{description && (
<div className="text-xs text-muted-foreground/70 mt-1.5 hidden sm:block">{description}</div>
)}
</div>
<Icon className="h-4 w-4 text-muted-foreground/50 shrink-0 mt-1.5" />
</div>
);
const surfaceClassName = `h-full px-4 py-4 sm:px-5 sm:py-5 rounded-lg transition-colors${isClickable ? " hover:bg-accent/50 cursor-pointer" : ""}`;
const inner = appearance === "card"
? <Card elevation="low" className={surfaceClassName}>{content}</Card>
: <div className={surfaceClassName}>{content}</div>;
if (to) {
return (
+4 -9
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TabBar, type TabSize } from "./TabBar";
import { useSidebar } from "../context/SidebarContext";
export interface PageTabItem {
@@ -13,9 +13,10 @@ interface PageTabBarProps {
value?: string;
onValueChange?: (value: string) => void;
align?: "center" | "start";
size?: TabSize;
}
export function PageTabBar({ items, value, onValueChange, align = "center" }: PageTabBarProps) {
export function PageTabBar({ items, value, onValueChange, align = "center", size = "standard" }: PageTabBarProps) {
const { isMobile } = useSidebar();
if (isMobile && value !== undefined && onValueChange) {
@@ -39,12 +40,6 @@ export function PageTabBar({ items, value, onValueChange, align = "center" }: Pa
}
return (
<TabsList variant="line" className={align === "start" ? "justify-start" : undefined}>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value}>
{item.label}
</TabsTrigger>
))}
</TabsList>
<TabBar items={items} size={size} className={align === "start" ? "justify-start" : undefined} />
);
}
+71
View File
@@ -0,0 +1,71 @@
import { ArrowUpDown, Check, Plus } from "lucide-react";
import { ToolbarSearch } from "./ToolbarSearch";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
export type ProjectSortField = "name" | "updated" | "created" | "targetDate";
export type ProjectSortDir = "asc" | "desc";
const PROJECT_SORT_OPTIONS: Array<{ field: ProjectSortField; label: string }> = [
{ field: "name", label: "Name" },
{ field: "updated", label: "Updated" },
{ field: "created", label: "Created" },
{ field: "targetDate", label: "Target date" },
];
/** Canonical project search, sorting and creation controls. State stays with the page. */
export function ProjectsToolbar({ query, onQueryChange, sortField, sortDir, onSortChange, onNewProject: openNewProject }: {
query: string;
onQueryChange: (value: string) => void;
sortField: ProjectSortField;
sortDir: ProjectSortDir;
onSortChange: (field: ProjectSortField, direction: ProjectSortDir) => void;
onNewProject: () => void;
}) {
const sortLabel = PROJECT_SORT_OPTIONS.find(option => option.field === sortField)?.label ?? "Name";
return <div data-canonical-toolbar="projects" role="toolbar" aria-label="Projects controls" className="flex flex-wrap items-center gap-4">
<ToolbarSearch query={query} onQueryChange={onQueryChange} label="Search projects" placeholder="Search projects…" />
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" title="Sort">
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span>Sort: {sortLabel}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{PROJECT_SORT_OPTIONS.map((option) => (
<button
key={option.field}
type="button"
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
sortField === option.field
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => {
onSortChange(option.field, sortField === option.field
? (sortDir === "asc" ? "desc" : "asc")
: (option.field === "name" || option.field === "targetDate" ? "asc" : "desc"));
}}
>
<span>{option.label}</span>
{sortField === option.field ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Check className="h-3 w-3" />
{sortDir === "asc" ? "Asc" : "Desc"}
</span>
) : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<div className="design-toolbar-actions">
<Button size="sm" variant="outline" data-toolbar-primary onClick={openNewProject}>
<Plus className="h-4 w-4 mr-1" />
Add Project
</Button>
</div>
</div>;
}
@@ -7,6 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { PropertiesPanel } from "./PropertiesPanel";
const mockSandbox = vi.hoisted(() => ({ enabled: false }));
vi.mock("@/context/DesignSandboxContext", () => ({ useDesignSandbox: () => mockSandbox.enabled }));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
@@ -81,6 +84,7 @@ describe("PropertiesPanel", () => {
mockSetPanelVisible.mockClear();
mockClearPanelMaximizeRequest.mockClear();
mockPanelState.panelMaximizeRequested = false;
mockSandbox.enabled = false;
});
afterEach(() => {
@@ -91,6 +95,7 @@ describe("PropertiesPanel", () => {
container.remove();
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
vi.clearAllMocks();
document.documentElement.style.removeProperty("--design-page-aside-width");
});
describe("classic task interface on (legacy panel)", () => {
@@ -143,6 +148,28 @@ describe("PropertiesPanel", () => {
);
});
it("uses the proposal width token and resets only the sandbox's saved width", async () => {
mockSandbox.enabled = true;
document.documentElement.style.setProperty("--design-page-aside-width", "320px");
window.localStorage.setItem("taskChatRedesign.taskDetailPropertiesPaneWidth", "434");
window.localStorage.setItem("paperclip.design-proposal.properties-pane-width.task-detail", "480");
await renderPanel({ taskDetailLayout: true });
const aside = container.querySelector("aside")!;
expect(aside.style.width).toBe("480px");
const grip = aside.querySelector('[aria-label="Resize panel"]')!;
grip.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
await flushReact();
expect(aside.style.width).toBe("320px");
expect(window.localStorage.getItem("paperclip.design-proposal.properties-pane-width.task-detail")).toBeNull();
expect(window.localStorage.getItem("taskChatRedesign.taskDetailPropertiesPaneWidth")).toBe("434");
// Reset reads the current token, including subsequent foundation tuning.
document.documentElement.style.setProperty("--design-page-aside-width", "360px");
grip.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
await flushReact();
expect(aside.style.width).toBe("360px");
});
it("removes the left divider only while the sidebar is maximized", async () => {
await renderPanel({ taskDetailLayout: true });
const aside = container.querySelector("aside")!;
+22 -7
View File
@@ -7,6 +7,7 @@ import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SidePanelFrame, SidePanelWindowControls } from "@/components/side-panel";
import { useDesignSandbox } from "@/context/DesignSandboxContext";
export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) {
const {
@@ -19,6 +20,7 @@ export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout
} = usePanel();
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const designSandbox = useDesignSandbox();
const streamlinedTaskDetailLayout = streamlinedUiEnabled && taskDetailLayout;
if (!panelContent) return null;
@@ -46,12 +48,13 @@ export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout
return (
<ResizablePropertiesPanel
key={streamlinedTaskDetailLayout ? "task-detail" : "default"}
key={`${streamlinedTaskDetailLayout ? "task-detail" : "default"}-${designSandbox ? "proposal" : "production"}`}
panelContent={panelContent}
panelContentMode={panelContentMode}
panelVisible={panelVisible}
setPanelVisible={setPanelVisible}
taskDetailLayout={streamlinedTaskDetailLayout}
designSandbox={designSandbox}
maximizeRequested={panelMaximizeRequested}
clearMaximizeRequest={clearPanelMaximizeRequest}
/>
@@ -80,6 +83,7 @@ export const PROPERTIES_PANE_FOOTER_SLOT_ID = "properties-pane-footer-slot";
const WIDTH_STORAGE_KEY = "taskChatRedesign.propertiesPaneWidth";
const TASK_DETAIL_WIDTH_STORAGE_KEY = "taskChatRedesign.taskDetailPropertiesPaneWidth";
const PROPOSAL_WIDTH_STORAGE_KEY = "paperclip.design-proposal.properties-pane-width";
const DEFAULT_PANE_WIDTH = 322;
const TASK_DETAIL_DEFAULT_PANE_WIDTH = 434;
const MIN_PANE_WIDTH = 260;
@@ -94,6 +98,12 @@ const MAXIMIZED_CONTENT_MAX_WIDTH = 840;
*/
const RESTORE_FALLBACK_DELAY = 400;
function readProposalPaneWidth(fallback: number): number {
if (typeof window === "undefined") return fallback;
const tokenWidth = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--design-page-aside-width"));
return Number.isFinite(tokenWidth) && tokenWidth > 0 ? tokenWidth : fallback;
}
function clampPaneWidth(width: number): number {
const max =
typeof window === "undefined"
@@ -153,6 +163,7 @@ interface ResizablePropertiesPanelProps {
panelVisible: boolean;
setPanelVisible: (visible: boolean) => void;
taskDetailLayout: boolean;
designSandbox: boolean;
/** Pending `viewer=full` deep-link request (LOOA-2181); cleared once consumed. */
maximizeRequested: boolean;
clearMaximizeRequest: () => void;
@@ -164,13 +175,17 @@ function ResizablePropertiesPanel({
panelVisible,
setPanelVisible,
taskDetailLayout,
designSandbox,
maximizeRequested,
clearMaximizeRequest,
}: ResizablePropertiesPanelProps) {
const defaultPaneWidth = taskDetailLayout
const productionPaneWidth = taskDetailLayout
? TASK_DETAIL_DEFAULT_PANE_WIDTH
: DEFAULT_PANE_WIDTH;
const widthStorageKey = taskDetailLayout
const defaultPaneWidth = designSandbox ? readProposalPaneWidth(productionPaneWidth) : productionPaneWidth;
const widthStorageKey = designSandbox
? `${PROPOSAL_WIDTH_STORAGE_KEY}.${taskDetailLayout ? "task-detail" : "default"}`
: taskDetailLayout
? TASK_DETAIL_WIDTH_STORAGE_KEY
: WIDTH_STORAGE_KEY;
const [width, setWidth] = useState(() =>
@@ -264,9 +279,9 @@ function ResizablePropertiesPanel({
}, [endDrag]);
const handleGripDoubleClick = useCallback(() => {
setWidth(defaultPaneWidth);
setWidth(designSandbox ? clampPaneWidth(readProposalPaneWidth(productionPaneWidth)) : defaultPaneWidth);
clearStoredPaneWidth(widthStorageKey);
}, [defaultPaneWidth, widthStorageKey]);
}, [defaultPaneWidth, designSandbox, productionPaneWidth, widthStorageKey]);
const handleMaximize = useCallback(() => {
const aside = asideRef.current;
@@ -359,7 +374,7 @@ function ResizablePropertiesPanel({
panelVisible ? "overflow-visible" : "overflow-hidden",
// The width/opacity transition would fight pointer-driven
// resizing, so it is suspended while dragging.
!dragging && "transition-(--tp-width-opacity) duration-200 ease-in-out",
!dragging && "transition-(--tp-width-opacity) duration-(--motion-pane-width-reset) ease-(--motion-ease-pane-width-reset)",
),
)}
style={
@@ -380,7 +395,7 @@ function ResizablePropertiesPanel({
aria-orientation="vertical"
aria-label="Resize panel"
data-dragging={dragging ? "" : undefined}
className="group absolute inset-y-0 z-10 cursor-col-resize touch-none"
className="pane-resize-handle group absolute inset-y-0 z-10 cursor-col-resize touch-none"
style={{ left: -4, width: 8 }}
onPointerDown={handleGripPointerDown}
onPointerMove={handleGripPointerMove}
+106
View File
@@ -0,0 +1,106 @@
import { ArrowUpDown, Check, Layers, Plus } from "lucide-react";
import { Link } from "@/lib/router";
import { ToolbarSearch } from "./ToolbarSearch";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
export type RoutineGroupBy = "folder" | "none" | "project" | "assignee";
export type RoutineSortField = "updated" | "created" | "title" | "lastRun";
export type RoutineSortDir = "asc" | "desc";
export function RoutinesToolbar({ query, onQueryChange, sortField, sortDir, groupBy, onSortChange, onGroupChange, onNewFolder, onCreateRoutine, runsHref, selectMode, onToggleSelect }: {
query: string;
onQueryChange: (value: string) => void;
sortField: RoutineSortField;
sortDir: RoutineSortDir;
groupBy: RoutineGroupBy;
onSortChange: (field: RoutineSortField, direction: RoutineSortDir) => void;
onGroupChange: (value: RoutineGroupBy) => void;
onNewFolder?: () => void;
onCreateRoutine: () => void;
runsHref?: string;
selectMode?: boolean;
onToggleSelect?: () => void;
}) {
return <div data-canonical-toolbar="routines" role="toolbar" aria-label="Routines controls" className="flex flex-wrap items-center gap-4">
<ToolbarSearch query={query} onQueryChange={onQueryChange} label="Search routines" placeholder="Search routines…" />
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" title="Sort">
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span >Sort</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["updated", "Updated"],
["created", "Created"],
["lastRun", "Last run"],
["title", "Title"],
] as const).map(([field, label]) => (
<button
type="button"
key={field}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
sortField === field
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => {
onSortChange(field, sortField === field
? (sortDir === "asc" ? "desc" : "asc")
: (field === "title" ? "asc" : "desc"));
}}
>
<span>{label}</span>
{sortField === field ? (
<span className="text-xs text-muted-foreground">
{sortDir === "asc" ? "Asc" : "Desc"}
</span>
) : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" title="Group">
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span >Group</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["folder", "Folder"],
["project", "Project"],
["assignee", "Agent"],
["none", "None"],
] as const).map(([value, label]) => (
<button
type="button"
key={value}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
groupBy === value
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => onGroupChange(value)}
>
<span>{label}</span>
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<div className="design-toolbar-actions">
{onToggleSelect && <Button variant="outline" size="sm" onClick={onToggleSelect}>{selectMode ? "Done" : "Select"}</Button>}
{runsHref && <Button variant="outline" size="sm" asChild><Link data-toolbar-control to={runsHref}>View all runs</Link></Button>}
{onNewFolder && <Button variant="outline" size="sm" onClick={onNewFolder}><Plus className="h-4 w-4" />New folder</Button>}
<Button variant="outline" size="sm" data-toolbar-primary onClick={onCreateRoutine}><Plus className="h-4 w-4" />Create routine</Button>
</div>
</div>;
}
+23 -16
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type CSSProperties } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
@@ -20,12 +20,14 @@ import { useSignOut } from "@/hooks/useSignOut";
import { useStagingCommit } from "@/hooks/useStagingCommit";
import { userProfilePath } from "@/lib/userProfileLinks";
import { useSidebar } from "../context/SidebarContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import { FlyoutContent, FlyoutHeader, FlyoutOptions } from "@/components/ui/flyout";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { ThemeToggle } from "./ThemeToggle";
import { SidebarServerInfo } from "./SidebarServerInfo";
import { TruncatedText } from "./TruncatedText";
const INVITES_PATH = "/company/settings/members?tab=invites";
const DOCS_URL = "https://docs.paperclip.ing/";
@@ -37,6 +39,8 @@ interface SidebarAccountMenuProps {
onOpenChange?: (open: boolean) => void;
/** Contextual navigation occupies a full sidebar even if the saved global nav mode is collapsed. */
forceExpanded?: boolean;
/** Optional local shadow preview; the shared token remains the default. */
shadow?: CSSProperties["boxShadow"];
}
interface MenuActionProps {
@@ -78,7 +82,7 @@ function MenuAction({
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
<TruncatedText className="min-w-0 flex-1 truncate">{label}</TruncatedText>
</>
);
@@ -118,6 +122,7 @@ export function SidebarAccountMenu({
open: controlledOpen,
onOpenChange,
forceExpanded = false,
shadow,
}: SidebarAccountMenuProps) {
const cloud = useCloudInstance();
const isCloud = Boolean(cloud);
@@ -185,17 +190,19 @@ export function SidebarAccountMenu({
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className={cn("min-w-0 flex-1 truncate", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>{displayName}</span>
<TruncatedText className={cn("min-w-0 flex-1 truncate", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>{displayName}</TruncatedText>
</button>
</PopoverTrigger>
<PopoverContent
<FlyoutContent
shadow={shadow}
aria-label="Account"
side="top"
align="start"
sideOffset={10}
className="min-h-(--profile-popover-min-height) w-(--profile-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
>
{/* The profile link is a stretched overlay so the staging SHA anchor can sit beside the email without nesting anchors. */}
<div className="relative flex h-(--profile-popover-header-height) shrink-0 items-center gap-2.5 px-3.5">
<FlyoutHeader data-flyout-profile data-marquee-surface className="relative flex h-(--profile-popover-header-height) shrink-0 items-center gap-2.5 px-3.5">
<Link
to={profileHref}
aria-label="View profile"
@@ -207,12 +214,12 @@ export function SidebarAccountMenu({
<AvatarFallback className="text-xs text-foreground">{initials}</AvatarFallback>
</Avatar>
<div className="pointer-events-none relative min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold leading-(--profile-popover-label-line-height) text-foreground">
<TruncatedText as="h2" className="truncate text-sm font-semibold leading-(--profile-popover-label-line-height) text-foreground">
{displayName}
</h2>
<p className="truncate text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground">
</TruncatedText>
<TruncatedText as="p" className="truncate text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground">
{secondaryLabel}
</p>
</TruncatedText>
{stagingCommit ? (
<a
className="pointer-events-auto block truncate font-mono text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground hover:underline focus-visible:underline"
@@ -226,9 +233,9 @@ export function SidebarAccountMenu({
</a>
) : null}
</div>
</div>
</FlyoutHeader>
<div className="flex flex-1 flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
<FlyoutOptions className="flex flex-1 flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
<MenuAction
label="Settings"
icon={Settings}
@@ -265,14 +272,14 @@ export function SidebarAccountMenu({
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<LogOut className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate">
<TruncatedText className="min-w-0 flex-1 truncate">
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
</span>
</TruncatedText>
</button>
) : null}
<SidebarServerInfo />
</div>
</PopoverContent>
</FlyoutOptions>
</FlyoutContent>
</Popover>
{!rail && !isCloud ? (
<Tooltip>
+26 -21
View File
@@ -41,12 +41,15 @@ import { queryKeys } from "@/lib/queryKeys";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
import { useSidebar } from "../context/SidebarContext";
import { CompanyPatternIcon } from "./CompanyPatternIcon";
import { TruncatedText } from "./TruncatedText";
import { FlyoutHeader, FlyoutOptions } from "./ui/flyout";
import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher";
interface SidebarCompanyMenuProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
showChevron?: boolean;
}
const TRIGGER_WORKSPACE_ICON_CLASS = "size-5 shrink-0 rounded-md text-(length:--text-micro)";
@@ -91,6 +94,7 @@ function SortableCompanyItem({
return (
<DropdownMenuItem
ref={setNodeRef}
data-selected={isSelected || undefined}
style={{
transform: CSS.Transform.toString(transform),
transition,
@@ -111,13 +115,13 @@ function SortableCompanyItem({
>
<WorkspaceIcon company={company} inPopover />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium leading-(--organization-popover-name-line-height)">
<TruncatedText className="block truncate font-medium leading-(--organization-popover-name-line-height)">
{company.name}
</span>
</TruncatedText>
{isEditing ? null : (
<span className="block truncate text-(length:--text-nano) leading-(--organization-popover-prefix-line-height) text-muted-foreground">
<TruncatedText className="block truncate text-(length:--text-nano) leading-(--organization-popover-prefix-line-height) text-muted-foreground">
{company.issuePrefix}
</span>
</TruncatedText>
)}
</span>
{isEditing ? (
@@ -144,15 +148,15 @@ function SortableCompanyItem({
);
}
export function SidebarCompanyMenu(props: SidebarCompanyMenuProps = {}) {
export function SidebarCompanyMenu({ showChevron, ...props }: SidebarCompanyMenuProps = {}) {
return (
<PluginOrganizationSwitcher {...props}>
<BuiltinCompanyMenu {...props} />
<BuiltinCompanyMenu {...props} showChevron={showChevron} />
</PluginOrganizationSwitcher>
);
}
function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps) {
function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange, showChevron = true }: SidebarCompanyMenuProps) {
const [internalOpen, setInternalOpen] = useState(false);
const [isEditingOrder, setIsEditingOrder] = useState(false);
const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } =
@@ -278,7 +282,8 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
control (~142px on mobile, which hides it) — search moved to
the nav to buy that width. A name that still
truncates stays hover-recoverable via title. */}
<span
<TruncatedText
marqueeResetKey={open}
className={cn(
"min-w-0 truncate text-sm font-bold text-foreground",
rail && SIDEBAR_RAIL_HIDDEN_LABEL,
@@ -286,9 +291,9 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
title={currentName ?? undefined}
>
{currentName ?? `Select ${switcherNoun}`}
</span>
</TruncatedText>
</span>
{!rail && <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />}
{!rail && showChevron && <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -296,7 +301,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
sideOffset={8}
className="ml-2 w-(--organization-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
>
<div className="flex h-(--organization-popover-header-height) items-center justify-between gap-2 px-3.5">
<FlyoutHeader className="flex h-(--organization-popover-header-height) items-center justify-between gap-2 px-3.5">
<DropdownMenuLabel className="p-0 text-(length:--text-compact) font-semibold text-foreground">
Organizations
</DropdownMenuLabel>
@@ -311,8 +316,8 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
>
{isEditingOrder ? "Done" : "Edit"}
</button>
</div>
<div className="flex max-h-96 flex-col gap-0.5 overflow-y-auto px-2.5 pb-2 pt-1">
</FlyoutHeader>
<FlyoutOptions className="flex max-h-96 flex-col gap-0.5 overflow-y-auto px-2.5 pb-2 pt-1">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@@ -356,8 +361,8 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
<DropdownMenuItem disabled>No organizations</DropdownMenuItem>
)
) : null}
</div>
<div className="flex flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
</FlyoutOptions>
<FlyoutOptions className="flex flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
{!companyCreationManaged && (
<DropdownMenuItem
onClick={addCompany}
@@ -367,7 +372,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<Plus className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate">Create organization</span>
<TruncatedText className="min-w-0 flex-1 truncate">Create organization</TruncatedText>
</DropdownMenuItem>
)}
{showInvitePeople ? (
@@ -385,9 +390,9 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<UserPlus className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate">
<TruncatedText className="min-w-0 flex-1 truncate">
{currentName ? `Invite people to ${currentName}` : "Invite people"}
</span>
</TruncatedText>
</Link>
</DropdownMenuItem>
) : null}
@@ -400,12 +405,12 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<LogOut className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate">
<TruncatedText className="min-w-0 flex-1 truncate">
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
</span>
</TruncatedText>
</DropdownMenuItem>
) : null}
</div>
</FlyoutOptions>
</DropdownMenuContent>
</DropdownMenu>
);
+2 -1
View File
@@ -6,6 +6,7 @@ import { useSidebar } from "../context/SidebarContext";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { TruncatedText } from "./TruncatedText";
/**
* Forces the full-label (non-rail) presentation for any `SidebarNavItem`
@@ -167,7 +168,7 @@ export function SidebarNavItem({
)}
</span>
) : null}
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : cn("min-w-0 flex-1 truncate", labelClassName)}>{label}</span>
<TruncatedText className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : cn("min-w-0 flex-1 truncate", labelClassName)}>{label}</TruncatedText>
{!rail && trailing}
{!rail && textBadge && (
<Badge variant="ghost"
+1 -1
View File
@@ -88,7 +88,7 @@ function SidebarSectionHeader({
"h-5 w-5 shrink-0 text-muted-foreground/60 transition-opacity hover:text-foreground data-[state=open]:opacity-100",
headerControlVisibilityClassName,
);
const headerContent = <span className={labelClassName}>{label}</span>;
const headerContent = <span data-slot="sidebar-group-label" className={labelClassName}>{label}</span>;
const HeaderActionIcon = headerAction?.icon;
const headingControl = hasMenu ? (
+25
View File
@@ -109,6 +109,31 @@ describe("SidebarShell", () => {
expect(indicator?.className).toContain("group-focus-visible:bg-ring");
});
it("restores and persists the default width on double-click, then resizes from that width", () => {
window.localStorage.setItem("test.sidebar.width", "320");
window.localStorage.setItem("other.sidebar.width", "400");
act(() => {
root.render(<SidebarShell open resizable storageKey="test.sidebar.width"><div>Sidebar</div></SidebarShell>);
});
const separator = handle()!;
separator.setPointerCapture = vi.fn();
act(() => {
separator.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
});
expect(spacer().style.width).toBe("240px");
expect(panel().style.width).toBe("240px");
expect(separator.getAttribute("aria-valuenow")).toBe("240");
expect(window.localStorage.getItem("test.sidebar.width")).toBe("240");
expect(window.localStorage.getItem("other.sidebar.width")).toBe("400");
act(() => {
separator.dispatchEvent(pointerEvent("pointerdown", 240));
separator.dispatchEvent(pointerEvent("pointermove", 272));
separator.dispatchEvent(pointerEvent("pointerup", 272));
});
expect(panel().style.width).toBe("272px");
expect(window.localStorage.getItem("test.sidebar.width")).toBe("272");
});
it("supports keyboard resizing and clamps to the configured bounds", () => {
act(() => {
root.render(
+27 -12
View File
@@ -11,6 +11,7 @@ import {
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import { usePaneWidthReset } from "@/hooks/usePaneWidthReset";
const DEFAULT_SIDEBAR_WIDTH = 240;
const MIN_SIDEBAR_WIDTH = 208;
@@ -86,7 +87,7 @@ type SidebarShellProps = {
*
* Opening/closing is intentionally instant (no width transition): the pin toggle
* and peek snap between rail and expanded widths so the content never appears to
* slide. Resizing the drag handle is likewise direct.
* slide. Resizing the drag handle is likewise direct; only double-click reset animates.
*/
export function SidebarShell({
children,
@@ -103,14 +104,19 @@ export function SidebarShell({
}: SidebarShellProps) {
const [width, setWidth] = useState(() => readStoredSidebarWidth(storageKey));
const [isResizing, setIsResizing] = useState(false);
const shellRef = useRef<HTMLDivElement>(null);
const { resetting, startReset, cancelReset, onResetTransitionEnd } = usePaneWidthReset();
const widthRef = useRef(width);
const dragState = useRef<{ startX: number; startWidth: number } | null>(null);
useEffect(() => {
cancelReset();
const storedWidth = readStoredSidebarWidth(storageKey);
widthRef.current = storedWidth;
setWidth(storedWidth);
}, [storageKey]);
}, [storageKey, cancelReset]);
useEffect(() => { cancelReset(); }, [open, collapsed, peeking, cancelReset]);
// Unified width function (plan §5): one code path for every pinned state.
const expandedWidth = open ? width : 0;
@@ -132,13 +138,15 @@ export function SidebarShell({
);
const commitWidth = useCallback(
(nextWidth: number) => {
(nextWidth: number, animate = false) => {
if (animate) startReset();
else cancelReset();
const clamped = clampSidebarWidth(nextWidth);
widthRef.current = clamped;
setWidth(clamped);
writeStoredSidebarWidth(storageKey, clamped);
},
[storageKey],
[storageKey, startReset, cancelReset],
);
const handlePointerDown = useCallback(
@@ -146,11 +154,15 @@ export function SidebarShell({
if (!canResize) return;
event.preventDefault();
const startWidth = resetting ? shellRef.current!.getBoundingClientRect().width : widthRef.current;
cancelReset();
widthRef.current = startWidth;
setWidth(startWidth);
event.currentTarget.setPointerCapture(event.pointerId);
dragState.current = { startX: event.clientX, startWidth: widthRef.current };
dragState.current = { startX: event.clientX, startWidth };
setIsResizing(true);
},
[canResize],
[canResize, resetting, cancelReset],
);
const handlePointerMove = useCallback(
@@ -176,13 +188,14 @@ export function SidebarShell({
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (!canResize) return;
const currentWidth = resetting ? shellRef.current!.getBoundingClientRect().width : width;
if (event.key === "ArrowLeft") {
event.preventDefault();
commitWidth(width - SIDEBAR_WIDTH_STEP);
commitWidth(currentWidth - SIDEBAR_WIDTH_STEP);
} else if (event.key === "ArrowRight") {
event.preventDefault();
commitWidth(width + SIDEBAR_WIDTH_STEP);
commitWidth(currentWidth + SIDEBAR_WIDTH_STEP);
} else if (event.key === "Home") {
event.preventDefault();
commitWidth(MIN_SIDEBAR_WIDTH);
@@ -191,14 +204,15 @@ export function SidebarShell({
commitWidth(MAX_SIDEBAR_WIDTH);
}
},
[canResize, commitWidth, width],
[canResize, commitWidth, width, resetting],
);
return (
<div className={cn("relative h-full shrink-0", className)} style={reservedStyle}>
<div ref={shellRef} className={cn("sidebar-shell relative h-full shrink-0", className)} style={reservedStyle}
data-width-resetting={resetting && canResize && !isResizing || undefined} onTransitionEnd={onResetTransitionEnd}>
<div
className={cn(
"absolute inset-y-0 left-0 flex flex-col overflow-hidden",
"sidebar-shell-panel absolute inset-y-0 left-0 flex flex-col overflow-hidden",
// Open/close is instant (PAP-10676): no width transition so the rail and
// expanded states snap without any sliding motion.
// Overlay styling only while the panel is wider than its reserved
@@ -226,13 +240,14 @@ export function SidebarShell({
aria-valuenow={width}
tabIndex={0}
data-dragging={isResizing ? "" : undefined}
className="group absolute inset-y-0 z-20 cursor-col-resize touch-none outline-none"
className="pane-resize-handle group absolute inset-y-0 z-20 cursor-col-resize touch-none outline-none"
style={{ right: -4, width: 8 }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endResize}
onPointerCancel={endResize}
onLostPointerCapture={endResize}
onDoubleClick={() => commitWidth(DEFAULT_SIDEBAR_WIDTH, true)}
onKeyDown={handleKeyDown}
>
<div
+7 -1
View File
@@ -66,6 +66,12 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
return "Blocked";
}
/** Shared accessible/tooltip copy, including waiting and covered-blocker states. */
export function getStatusIconLabel(status: string, externalConversationState?: "active" | "waiting" | null, blockerAttention?: IssueBlockerAttention | null): string {
const displayStatus = status === "in_review" && externalConversationState === "waiting" ? "idle" : status;
return status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(displayStatus);
}
/**
* Task/issue status indicator — renders the unified, color-blind-safe
* {@link StatusGlyph} (one distinct shape per status). With `onChange` it also
@@ -82,7 +88,7 @@ export function StatusIcon({ status, externalConversationState, blockerAttention
const [open, setOpen] = useState(false);
const displayStatus = status === "in_review" && externalConversationState === "waiting" ? "idle" : status;
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
const ariaLabel = status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(displayStatus);
const ariaLabel = getStatusIconLabel(status, externalConversationState, blockerAttention);
const glyphStatus = isCoveredBlocked ? "in_queue" : displayStatus;
const glyphIcon = (
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { ToggleGroup } from "radix-ui";
import { TabsList, TabsTrigger } from "./ui/tabs";
import { cn } from "@/lib/utils";
export type TabSize = "compact" | "standard";
export interface TabBarItem<T extends string = string> {
value: T;
label: ReactNode;
disabled?: boolean;
}
interface TabBarProps {
items: readonly TabBarItem[];
size?: TabSize;
label?: string;
className?: string;
}
/** Canonical content tabs. Compose inside Tabs with matching TabsContent panels. */
export function TabBar({ items, size = "standard", label, className }: TabBarProps) {
return <TabsList variant="line" data-canonical-tabs={size} aria-label={label} className={cn("canonical-tab-list", className)}>
{items.map(item => <TabsTrigger key={item.value} value={item.value} disabled={item.disabled} className="canonical-tab">{item.label}</TabsTrigger>)}
</TabsList>;
}
/** Same two visual sizes for collection filters; radio choices are not panels. */
export function TabFilters<T extends string>({ items, value, onValueChange, size = "standard", label, className }: {
items: readonly TabBarItem<T>[];
value: T;
onValueChange: (value: T) => void;
size?: TabSize;
label: string;
className?: string;
}) {
return <ToggleGroup.Root type="single" value={value} onValueChange={next => { if (next) onValueChange(next as T); }} data-canonical-tabs={size} aria-label={label} className={cn("canonical-tab-list", className)}>
{items.map(item => <ToggleGroup.Item key={item.value} value={item.value} disabled={item.disabled} className="canonical-tab">{item.label}</ToggleGroup.Item>)}
</ToggleGroup.Root>;
}
+3 -2
View File
@@ -4145,7 +4145,7 @@ describe("TaskChatThread live transcript", () => {
expect(tail!.textContent).toContain("Syncing workspace to environment");
expect(tail!.textContent).not.toContain("Waiting for transcript...");
// Without a runtime status, the generic wait message still shows.
// Without a runtime status, the working indicator remains the only visible activity.
renderThread({
comments: [],
onAdd: async () => {},
@@ -4155,7 +4155,8 @@ describe("TaskChatThread live transcript", () => {
const tail2 = container.querySelector(
'[data-testid="task-chat-live-transcript"]',
);
expect(tail2!.textContent).toContain("Waiting for transcript...");
expect(tail2!.textContent).toContain("Working");
expect(tail2!.textContent).not.toContain("Waiting for transcript...");
});
it("does not send a workspace bootstrap failure to connection settings", () => {
+3 -2
View File
@@ -1,3 +1,4 @@
import { TaskChatShimmerScope } from "@/components/task-chat/TaskChatShimmer";
import {
ISSUE_DETAIL_CONTENT_PAINT_MARK,
ISSUE_DETAIL_CONTENT_MEASURE,
@@ -3125,7 +3126,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}
/>
) : (
<>
<TaskChatShimmerScope items={tailItems}>
<TaskChatLiveRunPill
status={tailStatus}
execution={
@@ -3152,7 +3153,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
: "Waiting for transcript...")
}
/>
</>
</TaskChatShimmerScope>
)}
</div>
) : null}
+130
View File
@@ -0,0 +1,130 @@
import { Fragment, useState, type ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { ChevronDown, ChevronRight, GitBranch } from "lucide-react";
import { buildIssueTree } from "../lib/issue-tree";
import { issueFilterLabel } from "../lib/issue-filters";
import { allTaskListColumns, defaultTaskListColumns, defaultTaskListSort, sortTaskListItems, type TaskListColumnId, type TaskListSort } from "../lib/task-list";
import { timeAgo } from "../lib/timeAgo";
import { IssueRow, type IssueRowDensity } from "./IssueRow";
import { IssueGroupHeader } from "./IssueGroupHeader";
import { TaskListColumnHeader, TaskListColumnSettings, TaskListViewport } from "./TaskListColumns";
import { TaskRowGroup } from "./TaskRowGroup";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
export type TaskListItem = {
issue: Issue;
unread?: boolean;
createdBy?: string | null;
assignee?: string | null;
};
export type TaskListGrouping = "none" | "status" | "project";
/** Keep matching subtasks in their hierarchy without pulling in unrelated siblings. */
export function includeTaskListAncestors(items: readonly TaskListItem[], matches: readonly TaskListItem[]): TaskListItem[] {
const byId = new Map(items.map(item => [item.issue.id, item]));
const included = new Set<string>();
for (const match of matches) {
let item: TaskListItem | undefined = match;
while (item && !included.has(item.issue.id)) {
included.add(item.issue.id);
item = item.issue.parentId ? byId.get(item.issue.parentId) : undefined;
}
}
return items.filter(item => included.has(item.issue.id));
}
/** Opt-in collection built from the reviewed row, hierarchy and column primitives. */
export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState }: {
items: readonly TaskListItem[];
allItems?: readonly TaskListItem[];
initialColumns?: readonly TaskListColumnId[];
density?: IssueRowDensity;
collapsedIds?: ReadonlySet<string>;
onCollapsedIdsChange?: (ids: ReadonlySet<string>) => void;
groupBy?: TaskListGrouping;
emptyState?: ReactNode;
}) {
const [columns, setColumns] = useState(initialColumns);
const [columnOrder, setColumnOrder] = useState(allTaskListColumns);
const [sort, setSort] = useState<TaskListSort>();
const [localCollapsed, setLocalCollapsed] = useState<ReadonlySet<string>>(new Set());
const collapsed = collapsedIds ?? localCollapsed;
const setCollapsed = onCollapsedIdsChange ?? setLocalCollapsed;
const byId = new Map(allItems.map(item => [item.issue.id, item]));
const visibleById = new Map(items.map(item => [item.issue.id, item]));
const sortValue = (issue: Issue, column: TaskListColumnId) => {
const item = byId.get(issue.id)!;
const parent = issue.parentId ? byId.get(issue.parentId)?.issue : null;
switch (column) {
case "task": return issue.title;
case "taskId": return issue.identifier;
case "createdBy": return item.createdBy;
case "assignee": return item.assignee;
case "project": return issue.project?.name;
case "parentTask": return parent ? `${parent.identifier} ${parent.title}` : null;
case "tags": return issue.labels?.map(label => label.name).sort().join(", ");
case "worktree": return issue.currentExecutionWorkspace?.branchName;
case "lastUpdated": return new Date(issue.updatedAt).getTime();
}
};
const { roots, childMap } = buildIssueTree(sortTaskListItems(items.map(item => item.issue), sort ?? defaultTaskListSort, sortValue));
const updateColumns = (next: readonly TaskListColumnId[]) => {
setColumns(next);
if (sort && !next.includes(sort.column)) setSort(undefined);
};
const renderBranch = (issue: Issue, depth: number): ReactNode => {
const item = visibleById.get(issue.id)!;
const children = childMap.get(issue.id) ?? [];
const expanded = !collapsed.has(issue.id);
const parent = issue.parentId ? byId.get(issue.parentId)?.issue : null;
const branch = issue.currentExecutionWorkspace?.branchName;
return (
<Fragment key={issue.id}>
<IssueRow
issue={issue} presentation="task" appearance="refreshed" density={density}
unreadState={item.unread ? "visible" : "hidden"}
treeGuides={depth} chevronInGuide={depth > 0 && children.length > 0}
leadingControl={children.length ? (
<Button variant="ghost" size="icon-xs" className="task-row-disclosure" aria-label={`${expanded ? "Collapse" : "Expand"} ${issue.title}`} aria-expanded={expanded} onClick={() => {
const next = new Set(collapsed);
if (next.has(issue.id)) next.delete(issue.id); else next.add(issue.id);
setCollapsed(next);
}}>{expanded ? <ChevronDown aria-hidden /> : <ChevronRight aria-hidden />}</Button>
) : undefined}
columnValues={{
createdBy: item.createdBy,
assignee: item.assignee ?? "Unassigned",
project: issue.project?.name,
parentTask: parent ? <span><span className="font-mono">{parent.identifier}</span> {parent.title}</span> : undefined,
tags: issue.labels?.length ? <span className="inline-flex items-center gap-1">{issue.labels.map(label => <Badge key={label.id} variant="secondary" className="shrink-0 px-1.5 py-0">{label.name}</Badge>)}</span> : undefined,
worktree: branch ? <span className="inline-flex items-center gap-1.5"><GitBranch className="size-3 shrink-0" aria-hidden />{branch}</span> : undefined,
lastUpdated: timeAgo(issue.updatedAt),
}}
/>
{expanded ? children.map(child => renderBranch(child, depth + 1)) : null}
</Fragment>
);
};
const groups = new Map<string, Issue[]>();
for (const root of roots) {
const label = groupBy === "status" ? issueFilterLabel(root.status) : groupBy === "project" ? root.project?.name ?? "No project" : "";
groups.set(label, [...(groups.get(label) ?? []), root]);
}
return (
<TaskListViewport visibleColumns={columns} columnOrder={columnOrder} controls={<TaskListColumnSettings visibleColumns={columns} onVisibleColumnsChange={updateColumns} onColumnOrderChange={setColumnOrder} onResetDefaults={() => setSort(undefined)} />}>
<TaskListColumnHeader sort={sort} onSortChange={setSort} onColumnOrderChange={setColumnOrder} />
{items.length === 0 ? emptyState : (
<div aria-label={`${density} task list`}>
{[...groups].map(([label, groupRoots]) => (
<Fragment key={label}>
{label ? <IssueGroupHeader label={label} className="task-list-group-heading" trailing={<span className="text-xs text-muted-foreground">{groupRoots.length}</span>} /> : null}
{groupRoots.map(issue => <TaskRowGroup key={issue.id} role="rowgroup" aria-label={`${issue.title} task group`}>{renderBranch(issue, 0)}</TaskRowGroup>)}
</Fragment>
))}
</div>
)}
</TaskListViewport>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { createContext, useContext, useId, type CSSProperties, type ReactNode } from "react";
import { DndContext, PointerSensor, closestCenter, pointerWithin, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ArrowDown, ArrowUp, Ellipsis } from "lucide-react";
import { Button } from "./ui/button";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
import { allTaskListColumns, defaultTaskListColumns, reorderTaskListColumns, taskListColumnStart, taskListColumns, type TaskListColumnId, type TaskListSort } from "../lib/task-list";
import { CellMarquee } from "./CellMarquee";
import { FlyoutAppearanceProvider, FlyoutHeader, FlyoutOptions } from "./ui/flyout";
import { useHorizontalScrollEdges } from "@/hooks/useHorizontalScrollEdges";
export { taskListColumns } from "../lib/task-list";
export type { TaskListColumnId, TaskListSort } from "../lib/task-list";
const ColumnContext = createContext({ columns: defaultTaskListColumns, order: allTaskListColumns });
export const useTaskListColumns = () => useContext(ColumnContext).columns;
function columnLayout(visibleColumns: readonly TaskListColumnId[], hasControls: boolean): CSSProperties {
const columns = visibleColumns.map(id => taskListColumns.find(column => column.id === id)!);
const hasTitle = visibleColumns.includes("task");
const leadingWidth = hasTitle ? "var(--task-list-column-status)" : "var(--task-row-control-size)";
const widths = columns.flatMap(column => column.id === "task" ? [leadingWidth, "var(--task-list-column-title-min)"] : [column.width]);
const tracks = columns.flatMap(column => column.id === "task" ? [leadingWidth, "minmax(var(--task-list-column-title-min), 1fr)"] : [column.width]);
if (!hasTitle) { widths.unshift(leadingWidth); tracks.unshift(`minmax(${leadingWidth}, 1fr)`); }
return {
"--task-list-columns": tracks.join(" "),
"--task-list-task-start": hasTitle ? taskListColumnStart(visibleColumns, "task") : 1,
"--task-list-min-width": `calc(${widths.join(" + ")} + var(--task-row-gap) * ${widths.length - 1} + var(--task-row-padding-inline) * 2${hasControls ? " + var(--task-list-controls-width)" : ""})`,
"--task-list-controls-space": hasControls ? "var(--task-list-controls-width)" : "var(--task-list-no-controls-space)",
} as CSSProperties;
}
/** Shared collection viewport: fades only the edges with content beyond them. */
export function TaskListViewport({ children, visibleColumns = defaultTaskListColumns, columnOrder = allTaskListColumns, controls }: {
children: ReactNode;
visibleColumns?: readonly TaskListColumnId[];
columnOrder?: readonly TaskListColumnId[];
controls?: ReactNode;
}) {
const { scrollRef, contentRef, edges } = useHorizontalScrollEdges();
const orderedColumns = columnOrder.filter(id => visibleColumns.includes(id));
return (
<ColumnContext.Provider value={{ columns: orderedColumns, order: columnOrder }}>
<div role="table" aria-label="Task list" data-slot="task-list-viewport" data-overflow-left={edges.left} data-overflow-right={edges.right} className="task-list-viewport" style={columnLayout(orderedColumns, Boolean(controls))}>
<div ref={scrollRef} role="region" aria-label="Task list columns" tabIndex={0} className="task-list-columns-scroll">
<div ref={contentRef} className="task-list-columns-content">{children}</div>
</div>
{controls ? <div className="task-list-header-controls">{controls}</div> : null}
</div>
</ColumnContext.Provider>
);
}
const detailColumns = taskListColumns.filter(column => column.id !== "task");
type DetailColumn = Exclude<(typeof detailColumns)[number]["id"], "taskId">;
/** Values are resolved by the collection; identity columns intentionally accept names only. */
export type TaskRowColumnValues = Partial<Record<Exclude<DetailColumn, "createdBy" | "assignee">, ReactNode>> & {
createdBy?: string | null;
assignee?: string | null;
};
function SortableColumnHeader({ id, label, start, sort, onSortChange, onColumnOrderChange, helpId }: {
id: TaskListColumnId;
label: string;
start: number;
sort?: TaskListSort;
onSortChange?: (sort: TaskListSort) => void;
onColumnOrderChange?: (columns: readonly TaskListColumnId[]) => void;
helpId: string;
}) {
const { columns, order } = useContext(ColumnContext);
const sortable = useSortable({ id, disabled: !onColumnOrderChange, transition: null });
const active = sort?.column === id;
const direction = active && sort.direction === "ascending" ? "descending" : "ascending";
return (
<span
ref={sortable.setNodeRef}
role="columnheader"
data-column={id}
data-dragging={sortable.isDragging || undefined}
aria-colspan={id === "task" ? 2 : undefined}
aria-sort={active ? sort.direction : "none"}
style={{
gridColumn: `${start} / span ${id === "task" ? 2 : 1}`,
transform: sortable.transform ? CSS.Translate.toString({ ...sortable.transform, y: 0 }) : undefined,
}}
>
{onSortChange || onColumnOrderChange ? (
<Button
ref={sortable.setActivatorNodeRef}
{...sortable.attributes}
{...sortable.listeners}
type="button" variant="ghost" size="sm" className="task-list-sort-button"
data-active={active || undefined} data-reorderable={Boolean(onColumnOrderChange)}
aria-label={onSortChange ? `${label}: sort ${direction}` : label}
aria-describedby={onColumnOrderChange ? helpId : undefined}
aria-keyshortcuts={onColumnOrderChange ? "Alt+ArrowLeft Alt+ArrowRight" : undefined}
onClick={() => onSortChange?.({ column: id, direction })}
onKeyDown={event => {
if (!onColumnOrderChange || !event.altKey || !["ArrowLeft", "ArrowRight"].includes(event.key)) return;
event.preventDefault();
const target = columns[columns.indexOf(id) + (event.key === "ArrowLeft" ? -1 : 1)];
if (target) onColumnOrderChange(reorderTaskListColumns(order, id, target));
}}
>
<span>{label}</span>
{active ? sort.direction === "ascending"
? <ArrowUp className="size-3" data-slot="task-list-sort-direction" aria-hidden />
: <ArrowDown className="size-3" data-slot="task-list-sort-direction" aria-hidden /> : null}
</Button>
) : label}
</span>
);
}
export function TaskListColumnHeader({ sort, onSortChange, onColumnOrderChange }: {
sort?: TaskListSort;
onSortChange?: (sort: TaskListSort) => void;
onColumnOrderChange?: (columns: readonly TaskListColumnId[]) => void;
}) {
const { columns, order } = useContext(ColumnContext);
const helpId = useId();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
return (
<DndContext sensors={sensors} collisionDetection={args => {
const targets = pointerWithin(args);
return targets.length ? targets : closestCenter(args);
}} onDragEnd={({ active, over }) => {
if (over) onColumnOrderChange?.(reorderTaskListColumns(order, active.id as TaskListColumnId, over.id as TaskListColumnId));
}} accessibility={{ screenReaderInstructions: { draggable: "Click to sort. Drag horizontally to reorder, or use Alt plus Left or Right Arrow." } }}>
<span id={helpId} className="sr-only">Drag to reorder columns, or use Alt plus Left or Right Arrow. Click to sort.</span>
<SortableContext items={[...columns]} strategy={horizontalListSortingStrategy}>
<div role="row" data-slot="task-list-column-header" className="task-list-column-header">
{!columns.includes("task") ? <span role="columnheader" aria-label="Task hierarchy" /> : null}
{columns.map(id => <SortableColumnHeader key={id} id={id} label={taskListColumns.find(column => column.id === id)!.label} start={taskListColumnStart(columns, id)} sort={sort} onSortChange={onSortChange} onColumnOrderChange={onColumnOrderChange} helpId={helpId} />)}
</div>
</SortableContext>
</DndContext>
);
}
/** Compact refresh variant; the existing inbox picker uses legacy column IDs. */
export function TaskListColumnSettings({ visibleColumns, onVisibleColumnsChange, onColumnOrderChange, onResetDefaults }: {
visibleColumns: readonly TaskListColumnId[];
onVisibleColumnsChange: (columns: readonly TaskListColumnId[]) => void;
onColumnOrderChange?: (columns: readonly TaskListColumnId[]) => void;
onResetDefaults?: () => void;
}) {
const { order } = useContext(ColumnContext);
return (
<FlyoutAppearanceProvider><DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon-xs" className="task-list-settings-trigger" aria-label="Columns"><Ellipsis aria-hidden /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="task-list-options" aria-label="Columns">
<DropdownMenuLabel asChild><FlyoutHeader className="task-list-options-heading">Columns</FlyoutHeader></DropdownMenuLabel>
<FlyoutOptions className="task-list-options-items">
{order.map(id => (
<DropdownMenuCheckboxItem
key={id}
className="task-list-options-item"
checked={visibleColumns.includes(id)}
disabled={visibleColumns.length === 1 && visibleColumns.includes(id)}
onSelect={event => event.preventDefault()}
onCheckedChange={checked => onVisibleColumnsChange(order.filter(column => column === id ? checked === true : visibleColumns.includes(column)))}
>
{taskListColumns.find(column => column.id === id)!.label}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuItem className="task-list-options-reset" onSelect={event => { event.preventDefault(); onColumnOrderChange?.(allTaskListColumns); onVisibleColumnsChange(defaultTaskListColumns); onResetDefaults?.(); }}>Reset defaults</DropdownMenuItem>
</FlyoutOptions>
</DropdownMenuContent>
</DropdownMenu></FlyoutAppearanceProvider>
);
}
export function TaskRowColumnCells({ identifier, values }: { identifier: string; values: TaskRowColumnValues }) {
const columns = useTaskListColumns();
if (!detailColumns.some(column => columns.includes(column.id))) return null;
return (
<span data-slot="task-row-columns" className="task-row-columns">
{columns.filter(id => id !== "task").map(id => (
<span key={id} role="cell" data-column={id} data-marquee-cell={id} className="task-row-column" style={{ gridColumn: taskListColumnStart(columns, id) }}>
<CellMarquee cell={id}>{id === "taskId" ? identifier : values[id] ?? "—"}</CellMarquee>
</span>
))}
</span>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { ComponentProps, Ref } from "react";
import { ChevronDown, CirclePlus, Columns3, List, ListTree } from "lucide-react";
import { IssueFiltersPopover } from "./IssueFiltersPopover";
import { Button } from "./ui/button";
import { ToolbarSearch } from "./ToolbarSearch";
import { FlyoutHeader, FlyoutOptions } from "./ui/flyout";
import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { taskListViews as views, type TaskListView } from "@/lib/task-list-views";
/** Canonical proposed task toolbar; review under Components / Toolbars / Tasks. */
export function TaskListToolbar({ view, onViewChange, query, onQueryChange, searchRef, layout, onLayoutChange, allExpanded, onToggleExpanded, onNewTask, filters }: {
view: TaskListView;
onViewChange: (view: TaskListView) => void;
query: string;
onQueryChange: (query: string) => void;
searchRef?: Ref<HTMLInputElement>;
layout: "list" | "board";
onLayoutChange: (layout: "list" | "board") => void;
allExpanded: boolean;
onToggleExpanded: () => void;
onNewTask: () => void;
filters: ComponentProps<typeof IssueFiltersPopover>;
}) {
return (
<div data-canonical-toolbar="tasks" className="design-page-toolbar" role="toolbar" aria-label="Task list controls">
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="outline" className="design-toolbar-button design-view-trigger" aria-label={`Task view: ${views.find(option => option.value === view)!.label}`}>{views.find(option => option.value === view)!.label}<ChevronDown aria-hidden /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="start" className="task-list-options" aria-label="Task views">
<DropdownMenuLabel asChild><FlyoutHeader className="task-list-options-heading">Views</FlyoutHeader></DropdownMenuLabel>
<DropdownMenuRadioGroup asChild value={view} onValueChange={value => onViewChange(value as TaskListView)}><FlyoutOptions className="task-list-options-items">
{views.map(option => <DropdownMenuRadioItem className="task-list-options-item" key={option.value} value={option.value}>{option.label}</DropdownMenuRadioItem>)}
</FlyoutOptions></DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<ToolbarSearch query={query} onQueryChange={onQueryChange} inputRef={searchRef} label="Search tasks" placeholder="Search tasks…" />
<div className="design-view-toggle" role="group" aria-label="Task layout">
{[{ value: "list", label: "List view", icon: List }, { value: "board", label: "Board view", icon: Columns3 }].map(({ value, label, icon: Icon }) => (
<Tooltip key={value}><TooltipTrigger asChild><Button variant="ghost" className="design-toolbar-button design-toolbar-icon" aria-label={label} aria-pressed={layout === value} onClick={() => onLayoutChange(value as "list" | "board")}><Icon aria-hidden /></Button></TooltipTrigger><TooltipContent>{label}</TooltipContent></Tooltip>
))}
</div>
<div className="design-toolbar-actions">
<Tooltip><TooltipTrigger asChild><Button variant="outline" className="design-toolbar-button design-toolbar-icon" disabled={layout !== "list"} aria-label={allExpanded ? "Collapse all tasks" : "Expand all tasks"} onClick={onToggleExpanded}><ListTree aria-hidden /></Button></TooltipTrigger><TooltipContent>{allExpanded ? "Collapse all tasks" : "Expand all tasks"}</TooltipContent></Tooltip>
<div className="design-toolbar-filter"><IssueFiltersPopover {...filters} presentation="refreshed" buttonVariant="outline" iconOnly enableExternalObjectFilters={false} /></div>
<Button className="design-toolbar-button design-new-task" onClick={onNewTask}><CirclePlus aria-hidden />New Task</Button>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { ComponentProps } from "react";
import { cn } from "../lib/utils";
/**
* One root task and its visible descendants. The group owns the outer dividers;
* IssueRow owns indentation and vertical guides, with no interior separators.
* Render separate groups as adjacent siblings to share their boundary line.
*/
export function TaskRowGroup({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="task-row-group"
role="group"
className={cn("task-row-group", className)}
{...props}
/>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Issue } from "@paperclipai/shared";
import { timeAgo } from "@/lib/timeAgo";
import { IssueRow } from "./IssueRow";
/** Flat, read-style summary of caller-ordered tasks. Reuses the canonical row's
* navigation, status tooltip, hover, focus and cell-local marquee. Rendering a
* summary never changes the task's actual read state. */
export function TaskSummaryList({ issues, label = "Recent tasks", showIdentifier = true, showUpdated = true, issueLinkState }: {
issues: readonly Issue[];
label?: string;
showIdentifier?: boolean;
showUpdated?: boolean;
issueLinkState?: unknown;
}) {
return <ul className="task-summary-list" aria-label={label}>
{issues.map(issue => <li key={issue.id}>
<IssueRow issue={issue} presentation="task" appearance="refreshed" density="comfortable"
className="task-summary-row" unreadState="hidden" showIdentifier={showIdentifier}
trailingMeta={showUpdated ? timeAgo(issue.updatedAt) : undefined} issueLinkState={issueLinkState} />
</li>)}
</ul>;
}
+21
View File
@@ -0,0 +1,21 @@
import type { Ref } from "react";
import { Search, X } from "lucide-react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
/** Shared collection search field: one surface, focus ring and clear affordance. */
export function ToolbarSearch({ query, onQueryChange, label, placeholder, clearLabel = "Clear search", inputRef, inputType = "text" }: {
query: string;
onQueryChange: (value: string) => void;
label: string;
placeholder: string;
clearLabel?: string;
inputRef?: Ref<HTMLInputElement>;
inputType?: "text" | "search";
}) {
return <div className="design-task-search" data-toolbar-search>
<Search aria-hidden />
<Input ref={inputRef} type={inputType} aria-label={label} placeholder={placeholder} value={query} onChange={event => onQueryChange(event.target.value)} />
{query && <Button variant="ghost" size="icon-xs" data-toolbar-clear aria-label={clearLabel} onClick={() => onQueryChange("")}><X aria-hidden /></Button>}
</div>;
}
+22
View File
@@ -0,0 +1,22 @@
import { createContext, useContext, type ReactNode } from "react";
import { CellMarquee } from "./CellMarquee";
const MarqueeContext = createContext(false);
/** Opt-in overflow treatment; existing production layouts keep their current labels. */
export function TruncatedTextMarqueeProvider({ children }: { children: ReactNode }) {
return <MarqueeContext.Provider value>{children}</MarqueeContext.Provider>;
}
export function TruncatedText({ children, className, title, as: Tag = "span", marqueeResetKey }: {
children: ReactNode;
className?: string;
title?: string;
as?: "span" | "h2" | "p";
marqueeResetKey?: string | number | boolean;
}) {
const marquee = useContext(MarqueeContext);
return <Tag className={className} title={marquee ? undefined : title}>{marquee
? <CellMarquee cell="truncated-label" interactWithParent tabbable={false} resetKey={marqueeResetKey}>{children}</CellMarquee>
: children}</Tag>;
}
@@ -0,0 +1,86 @@
import { Check, Layers } from "lucide-react";
import type { ArtifactGroupBy, ArtifactKindFilter } from "@/api/artifacts";
import { TabFilters, type TabSize } from "@/components/TabBar";
import { Button } from "@/components/ui/button";
import { ToolbarSearch } from "@/components/ToolbarSearch";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export const ARTIFACT_KIND_FILTERS: { value: ArtifactKindFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "image", label: "Images" },
{ value: "video", label: "Videos" },
{ value: "document", label: "Documents" },
{ value: "text", label: "Text" },
{ value: "file", label: "Files" },
];
export const ARTIFACT_GROUP_OPTIONS: { value: ArtifactGroupBy; label: string }[] = [
{ value: "none", label: "None" },
{ value: "task", label: "Task" },
{ value: "parent_task", label: "Parent task" },
];
export function artifactGroupByLabel(value: ArtifactGroupBy): string {
return ARTIFACT_GROUP_OPTIONS.find((option) => option.value === value)?.label ?? "None";
}
/** Canonical collection filters; compact standalone, standard in the toolbar. */
export function ArtifactKindFilters({ kind, onKindChange, size = "compact" }: { kind: ArtifactKindFilter; onKindChange: (value: ArtifactKindFilter) => void; size?: TabSize }) {
return <TabFilters items={ARTIFACT_KIND_FILTERS} value={kind} onValueChange={onKindChange} size={size} label="Filter artifacts by type" />;
}
export interface ArtifactsToolbarProps {
query: string;
onQueryChange: (value: string) => void;
groupBy: ArtifactGroupBy;
onGroupByChange: (value: ArtifactGroupBy) => void;
kind: ArtifactKindFilter;
onKindChange: (value: ArtifactKindFilter) => void;
}
/** Shared shipped Artifacts controls. Canonical proposal: Components / Toolbars / Artifacts. */
export function ArtifactsToolbar({ query: draftQuery, onQueryChange: setDraftQuery, groupBy, onGroupByChange: selectGroupBy, kind, onKindChange: selectKind }: ArtifactsToolbarProps) {
const grouping = groupBy !== "none";
return (
<div data-canonical-toolbar="artifacts" role="toolbar" aria-label="Artifacts controls" className="flex flex-col items-start gap-4">
<div className="design-toolbar-controls flex w-full flex-wrap items-center gap-4">
<ToolbarSearch query={draftQuery} onQueryChange={setDraftQuery} label="Search artifacts" placeholder="Search artifacts…" clearLabel="Clear artifact search" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
aria-label={`Group artifacts (currently ${artifactGroupByLabel(groupBy)})`}
title="Group artifacts"
data-testid="artifact-group-control"
data-group-by={groupBy}
data-active={grouping}
className={cn("h-8 w-8 shrink-0", grouping && "bg-accent")}
>
<Layers className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Group by</DropdownMenuLabel>
{ARTIFACT_GROUP_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
data-testid={`artifact-group-option-${option.value}`}
aria-selected={groupBy === option.value}
onSelect={() => selectGroupBy(option.value)}
className="justify-between"
>
{option.label}
{groupBy === option.value ? <Check className="h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<ArtifactKindFilters kind={kind} onKindChange={selectKind} size="standard" />
</div>
);
}
@@ -0,0 +1,77 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Pause, Play, RotateCcw } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
import { Button } from "../ui/button";
import { TaskChatThreadView } from "../task-chat/TaskChatThreadView";
import { transcriptToTaskChatItems } from "../task-chat/transcript-adapter";
import { parseCssTimeMs } from "../task-chat/motion-tokens";
import type { TaskChatMessageItem } from "../task-chat/task-chat-model";
import { CONVERSATION_PLAYBACK_STAGES, conversationPlaybackSnapshot, previewHistory, SYNTHETIC_FINAL, PLAYBACK_FOLLOW_UP_STAGE, PLAYBACK_FOLLOW_UP, PLAYBACK_VERIFICATION_FINAL } from "./task-thread-fixtures";
import { ConversationPlaybackTurn } from "./ConversationPlaybackTurn";
import { settledPreviewReply } from "./settled-preview-reply";
/** A local clock feeds cumulative synthetic transcript snapshots into the real
* conversation renderer. One thread persists throughout; no fixture carousel. */
export function ConversationPlayback({ header, agentName, agent, onShowSavedThread }: {
header: ReactNode; agentName: string; agent?: Agent; onShowSavedThread?: () => void;
}) {
const [elapsed, setElapsed] = useState(0);
const [started, setStarted] = useState(false);
const [playing, setPlaying] = useState(false);
const [generation, setGeneration] = useState(0);
const [stepMs, setStepMs] = useState(0);
const tick = useRef(0);
useEffect(() => {
const styles = getComputedStyle(document.documentElement);
setStepMs(parseCssTimeMs(styles.getPropertyValue("--motion-sandbox-playback-step")));
tick.current = parseCssTimeMs(styles.getPropertyValue("--motion-sandbox-stream-tick"));
}, []);
const duration = CONVERSATION_PLAYBACK_STAGES.reduce((sum, stage) => sum + stage.units * stepMs, 0);
const ended = started && duration > 0 && elapsed >= duration;
useEffect(() => {
if (!playing || !stepMs || !tick.current) return;
const timer = window.setInterval(() => setElapsed(value => Math.min(duration, value + tick.current)), tick.current);
return () => window.clearInterval(timer);
}, [playing, stepMs, duration]);
useEffect(() => { if (ended) setPlaying(false); }, [ended]);
let offset = 0;
let stage = 0;
for (const current of CONVERSATION_PLAYBACK_STAGES) {
if (elapsed < offset + current.units * stepMs) break;
offset += current.units * stepMs;
stage++;
}
const progress = stepMs && stage < CONVERSATION_PLAYBACK_STAGES.length ? (elapsed - offset) / (CONVERSATION_PLAYBACK_STAGES[stage].units * stepMs) : 1;
const snapshot = useMemo(() => conversationPlaybackSnapshot(stage, progress), [stage, progress]);
const parsed = useMemo(() => transcriptToTaskChatItems(snapshot.entries, { runId: "conversation-playback", agentName, running: !ended }), [snapshot, agentName, ended]);
const human = useMemo(() => previewHistory(agentName).human, [agentName]);
const followUp = stage >= PLAYBACK_FOLLOW_UP_STAGE;
const firstSnapshot = useMemo(() => followUp ? conversationPlaybackSnapshot(6, 1) : snapshot, [followUp, snapshot]);
const firstItems = useMemo(() => followUp ? transcriptToTaskChatItems(firstSnapshot.entries, { runId: "playback-count", agentName, running: false }) : parsed, [followUp, firstSnapshot, parsed, agentName]);
const firstResponse = useMemo((): TaskChatMessageItem => ({ id: "playback-response", kind: "message", author: "agent", authorName: agentName, agent, channel: "final", text: SYNTHETIC_FINAL, timestamp: "10:11 AM" }), [agentName, agent]);
const secondResponse = useMemo((): TaskChatMessageItem => ({ id: "playback-verification", kind: "message", author: "agent", authorName: agentName, agent, channel: "final", text: PLAYBACK_VERIFICATION_FINAL, timestamp: "10:13 AM" }), [agentName, agent]);
const firstSettled = useMemo(() => settledPreviewReply(firstResponse, conversationPlaybackSnapshot(6, 1).entries, "playback-count", false, CONVERSATION_PLAYBACK_STAGES.slice(0, PLAYBACK_FOLLOW_UP_STAGE).reduce((sum, item) => sum + item.units * stepMs, 0)), [firstResponse, stepMs]);
const secondSettled = useMemo(() => ended ? settledPreviewReply(secondResponse, snapshot.entries, "conversation-playback", false, CONVERSATION_PLAYBACK_STAGES.slice(PLAYBACK_FOLLOW_UP_STAGE).reduce((sum, item) => sum + item.units * stepMs, 0)) : [], [ended, secondResponse, snapshot.entries, stepMs]);
const followUpMessage = useMemo((): TaskChatMessageItem[] => [{ id: "playback-follow-up", kind: "message", author: "human", isCurrentUser: true, text: PLAYBACK_FOLLOW_UP, timestamp: "10:12 AM" }], []);
const restart = () => { setElapsed(0); setGeneration(value => value + 1); setStarted(true); setPlaying(true); };
return <section className="flex min-h-0 flex-1 flex-col" data-testid="conversation-playback" data-playback-state={ended ? "complete" : playing ? "playing" : started ? "paused" : "idle"} data-playback-stage={stage}>
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border bg-background py-2 text-xs">
<Button size="sm" variant="outline" disabled={!stepMs} onClick={() => !started || ended ? restart() : setPlaying(value => !value)}>
{ended ? <RotateCcw /> : playing ? <Pause /> : <Play />}
{ended ? "Replay conversation" : playing ? "Pause" : started ? "Resume conversation" : "Play conversation"}
</Button>
{started && !ended ? <Button size="sm" variant="ghost" onClick={restart}><RotateCcw />Replay</Button> : null}
{onShowSavedThread ? <Button size="sm" variant="ghost" onClick={onShowSavedThread}>Show saved thread</Button> : null}
<span className="text-muted-foreground" role="status">{started ? ended ? "Conversation complete" : `${playing ? "" : "Paused · "}${CONVERSATION_PLAYBACK_STAGES[stage]?.label}` : "Watch a typical conversation · synthetic preview"}</span>
</div>
<TaskChatThreadView key={generation} header={header} items={started ? [human] : []} contentKey={`${elapsed}:${generation}`} tail={started ? <div className="flex flex-col gap-8">
<ConversationPlaybackTurn settled={followUp} snapshot={firstSnapshot} items={firstItems} response={firstResponse} settledItems={firstSettled} />
{followUp ? <>
<TaskChatThreadView scroll={false} className="p-0 md:p-0" items={followUpMessage} />
<ConversationPlaybackTurn settled={ended} snapshot={snapshot} items={parsed} response={secondResponse} settledItems={secondSettled} />
</> : null}
</div> : undefined} />
</section>;
}
@@ -0,0 +1,35 @@
import { useMemo } from "react";
import type { ThreadPreviewSnapshot } from "./task-thread-fixtures";
import { MarkdownWordStreaming } from "../MarkdownWordStreaming";
import { TaskChatThreadView } from "../task-chat/TaskChatThreadView";
import { TaskChatLiveTail } from "../task-chat/TaskChatLiveTail";
import { TaskChatLiveRunPill, toolCountSummaryFromEntries } from "../task-chat/TaskChatLiveRunPill";
import { TaskChatProtocolCard } from "../task-chat/TaskChatProtocolCard";
import { TaskChatTurnHandoff } from "../task-chat/TaskChatTurnHandoff";
import { TaskChatShimmerScope } from "../task-chat/TaskChatShimmer";
import { TaskChatBubbleActions } from "../task-chat/TaskChatBubbleActions";
import type { TaskChatItem, TaskChatMessageItem, TaskChatRuntimeRequestItem } from "../task-chat/task-chat-model";
/** Streaming and durable replies share one render key and the same bubble.
* Only the activity region folds; the response moves with normal document flow. */
export function ConversationPlaybackTurn({ settled, snapshot, items, response, settledItems }: {
settled: boolean;
snapshot: ThreadPreviewSnapshot;
items: TaskChatItem[];
response: TaskChatMessageItem;
settledItems: TaskChatItem[];
}) {
const pending = items.find((item): item is TaskChatRuntimeRequestItem => item.kind === "protocol" && item.surface === "runtime_request" && item.status === "pending");
const streamed = items.find((item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final");
const liveReply = useMemo(() => streamed ? [{ ...response, text: streamed.text, timestamp: undefined, attachedTurn: undefined }] : [], [response, streamed]);
const actions = (item: TaskChatMessageItem) => <TaskChatBubbleActions copyText={item.text} />;
return <TaskChatTurnHandoff settled={settled} activity={settled ? null : <TaskChatShimmerScope items={items}>
{!pending ? <TaskChatLiveRunPill status={snapshot.status} startedAtMs={null} toolSummary={toolCountSummaryFromEntries(snapshot.entries)} /> : null}
<MarkdownWordStreaming.Provider value><TaskChatLiveTail items={items.filter(item => item !== pending)} excludeFinal emptyMessage={snapshot.emptyMessage} /></MarkdownWordStreaming.Provider>
{pending ? <TaskChatProtocolCard key={pending.id} item={pending} presentation="takeover" /> : null}
</TaskChatShimmerScope>}>
{complete => <MarkdownWordStreaming.Provider value>
<TaskChatThreadView scroll={false} className="p-0 md:p-0" items={complete ? settledItems : liveReply} renderMessageActions={complete ? actions : undefined} />
</MarkdownWordStreaming.Provider>}
</TaskChatTurnHandoff>;
}
@@ -0,0 +1,172 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { ThemeProvider } from "@/context/ThemeContext";
import DesignSandboxTaskThread from "./DesignSandboxTaskThread";
import { THREAD_PREVIEW_CASES } from "./task-thread-fixtures";
import { transcriptToTaskChatItems } from "../task-chat/transcript-adapter";
// Real composer behavior is covered in the browser. Retain its takeover slots
// and callbacks here; the transcript/history/protocol surfaces are all real.
vi.mock("../task-chat/TaskChatComposer", async () => {
const { TaskChatPausedTakeover } = await import("../task-chat/TaskChatPausedTakeover");
return { TaskChatComposer: ({ onAdd, takeover, pause, onStop }: {
onAdd: (body: string) => void; takeover?: { content: ReactNode } | null;
pause?: Parameters<typeof TaskChatPausedTakeover>[0]; onStop?: () => void;
}) => pause ? <TaskChatPausedTakeover {...pause} /> : takeover ? takeover.content : <>
<button onClick={() => onAdd("Please check timeouts.")}>Send synthetic reply</button>
{onStop ? <button onClick={onStop}>Stop synthetic run</button> : null}
</> };
});
let host: HTMLDivElement;
let root: Root;
const network = vi.fn();
beforeEach(() => {
host = document.body.appendChild(document.createElement("div"));
root = createRoot(host);
vi.stubGlobal("fetch", network);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
network.mockReset();
});
afterEach(() => {
flushSync(() => root.unmount());
host.remove();
vi.unstubAllGlobals();
vi.useRealTimers();
document.documentElement.style.removeProperty("--motion-sandbox-stream-tick");
document.documentElement.style.removeProperty("--motion-sandbox-playback-step");
});
function render(query = "", defaultConversation = false, previewMode: "catalog" | "playback" = "catalog") {
flushSync(() => root.render(<MemoryRouter initialEntries={[`/DES/issues/DES-1${query}`]}>
<ThemeProvider>
<DesignSandboxTaskThread previewMode={previewMode} header={<h2>Task title</h2>} agentName="Preview engineer" defaultConversation={defaultConversation}>
<p>Saved conversation</p>
</DesignSandboxTaskThread>
</ThemeProvider>
</MemoryRouter>));
}
function button(label: string) {
const match = [...host.querySelectorAll("button")].find(el => el.textContent === label);
expect(match, `button ${label}`).toBeTruthy();
return match!;
}
it("leaves the saved conversation as the default on ordinary sandbox tasks", () => {
render();
expect(host.textContent).toContain("Saved conversation");
expect(host.querySelector("[data-preview-case]")).toBeNull();
});
it("defaults the demonstration task to a staging-style conversation", () => {
render("", true);
expect(host.querySelector('[data-preview-case="staging-conversation"]')).not.toBeNull();
expect(host.querySelector('[data-testid="task-chat-human-bubble"]')).not.toBeNull();
expect(host.querySelector('[data-testid="task-chat-bubble-attached-turn"]')).not.toBeNull();
expect(host.querySelector('[data-testid="task-chat-live-run-pill"]')).not.toBeNull();
expect(host.textContent).toContain("Thought through the task");
expect(host.textContent).toContain("Read files");
expect(host.querySelector('[data-testid="task-chat-runner-turn"]')).toBeNull();
expect(network).not.toHaveBeenCalled();
});
it("renders every snapshot through real thread surfaces", () => {
render("?threadPreview=all");
expect(host.querySelectorAll("[data-preview-case]")).toHaveLength(THREAD_PREVIEW_CASES.length);
expect(host.querySelector('[data-testid="task-chat-runner-turn"]')).not.toBeNull();
expect(host.querySelector('[data-testid="paused-composer-takeover"]')).not.toBeNull();
expect(host.textContent).toContain("Choose a reconnect policy");
expect(network).not.toHaveBeenCalled();
});
it("keeps completed commentary and tools in the Activity disclosure", async () => {
render("?threadPreview=completed");
const summaries = [...host.querySelectorAll<HTMLButtonElement>('[data-testid="task-chat-turn-summary"]')];
expect(summaries).toHaveLength(2);
expect(summaries.every(el => el.getAttribute("aria-expanded") === "false")).toBe(true);
await act(async () => summaries[1].click());
expect(summaries[1].getAttribute("aria-expanded")).toBe("true");
expect(host.textContent).toContain("The follow-up asks how I arrived at the count.");
expect(host.querySelector('[data-testid="task-chat-activity-phase-toggle"]')).not.toBeNull();
});
it("resolves permissions locally in the composer takeover", async () => {
render("?threadPreview=awaiting-approval");
await act(async () => button("Allow once").click());
expect(host.textContent?.toLowerCase()).toContain("resolved");
expect(network).not.toHaveBeenCalled();
});
it("stops synthetic activity without touching a real run", async () => {
render("?threadPreview=working");
await act(async () => button("Stop synthetic run").click());
expect(host.textContent).toContain("Run cancelled");
expect(host.querySelector('[data-testid="task-chat-live-run-pill"]')?.textContent).toContain("Stopped");
expect(network).not.toHaveBeenCalled();
});
it("keeps the native renderer separate from the staging transcript layout", () => {
render("?threadPreview=runner-running");
expect(host.querySelector('[data-testid="task-chat-runner-turn"]')).not.toBeNull();
expect(host.querySelector('[data-testid="task-chat-live-run-pill"]')).toBeNull();
});
it("preserves failure, incomplete-tool and streaming lifecycles through the real event adapter", () => {
const itemsFor = (id: string, running: boolean) => transcriptToTaskChatItems(
THREAD_PREVIEW_CASES.find(item => item.id === id)!.build().entries,
{ runId: "fixture-check", running },
);
expect(itemsFor("tool-failed", true)).toContainEqual(expect.objectContaining({ kind: "tool", status: "failed" }));
expect(itemsFor("tool-interrupted", false)).toContainEqual(expect.objectContaining({ kind: "tool", status: "interrupted" }));
expect(itemsFor("streaming-final", true)).toContainEqual(expect.objectContaining({ kind: "message", channel: "final", streaming: true }));
expect(itemsFor("native-failed", false)).toContainEqual(expect.objectContaining({ kind: "protocol", surface: "run_terminal", runState: "failed" }));
});
it("keeps replies local and restores the saved conversation", async () => {
render("?threadPreview=human-message");
await act(async () => button("Send synthetic reply").click());
expect(host.textContent).toContain("Please check timeouts.");
expect(host.textContent).toContain("Preview reply received.");
await act(async () => button("Show saved thread").click());
expect(host.textContent).toContain("Saved conversation");
expect(host.textContent).not.toContain("Preview reply received.");
expect(network).not.toHaveBeenCalled();
});
it("plays one cumulative conversation, freezes on pause, settles and replays locally", async () => {
vi.useFakeTimers();
document.documentElement.style.setProperty("--motion-sandbox-stream-tick", "40ms");
document.documentElement.style.setProperty("--motion-sandbox-playback-step", "4s");
await act(async () => render("?threadPreview=all", true, "playback"));
const playback = () => host.querySelector('[data-testid="conversation-playback"]')!;
expect(playback().getAttribute("data-playback-state")).toBe("idle");
expect(host.querySelectorAll('[data-testid="task-chat-human-bubble"]')).toHaveLength(0);
expect(host.querySelector("#sandbox-thread-state")).toBeNull();
await act(async () => button("Play conversation").click());
expect(host.querySelectorAll('[data-testid="task-chat-human-bubble"]')).toHaveLength(1);
await act(async () => vi.advanceTimersByTime(2500));
expect(playback().getAttribute("data-playback-stage")).toBe("1");
await act(async () => button("Pause").click());
const paused = host.textContent;
await act(async () => vi.advanceTimersByTime(10_000));
expect(host.textContent).toBe(paused);
expect(playback().getAttribute("data-playback-state")).toBe("paused");
await act(async () => button("Resume conversation").click());
await act(async () => vi.advanceTimersByTime(60_000));
expect(playback().getAttribute("data-playback-state")).toBe("complete");
expect(host.textContent).toContain("26 component source files");
expect(host.querySelector('[data-testid="task-chat-bubble-attached-turn"]')).not.toBeNull();
expect(host.querySelectorAll('[data-testid="task-chat-human-bubble"]')).toHaveLength(2);
expect(host.querySelectorAll('[data-testid="task-chat-agent-bubble"]')).toHaveLength(2);
expect(host.textContent).toContain("Verification complete");
await act(async () => button("Replay conversation").click());
expect(playback().getAttribute("data-playback-stage")).toBe("0");
expect(host.querySelector('[data-testid="task-chat-agent-bubble"]')).toBeNull();
await act(async () => button("Show saved thread").click());
expect(host.textContent).toContain("Saved conversation");
expect(host.querySelector('[data-testid="conversation-playback"]')).toBeNull();
expect(network).not.toHaveBeenCalled();
});
it("shows the saved conversation for an unknown playback preview", () => {
render("?threadPreview=unknown", true, "playback");
expect(host.textContent).toContain("Saved conversation");
expect(host.querySelector('[data-testid="conversation-playback"]')).toBeNull();
});
it("retains the saved conversation on ordinary tasks in playback mode", () => {
render("", false, "playback");
expect(host.textContent).toContain("Saved conversation");
expect(host.querySelector('[data-testid="conversation-playback"]')).toBeNull();
});
@@ -0,0 +1,172 @@
import { TaskChatShimmerScope } from "../task-chat/TaskChatShimmer";
import { revealWords, wordCount } from "./word-reveal";
import { MarkdownWordStreaming } from "../MarkdownWordStreaming";
import { ConversationPlayback } from "./ConversationPlayback";
import { settledPreviewReply as settledReply } from "./settled-preview-reply";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useSearchParams } from "react-router-dom";
import type { Agent, FeedbackVoteValue, IssueWorkMode } from "@paperclipai/shared";
import { useStreamlinedUiEnabled } from "../../hooks/useStreamlinedUiEnabled";
import { Button } from "../ui/button";
import { NativeSelect } from "../ui/select";
import { useScrollbarWhileScrolling } from "../side-panel/use-scrollbar-while-scrolling";
import { TaskChatThreadView } from "../task-chat/TaskChatThreadView";
import { TaskChatRunnerTurn } from "../task-chat/TaskChatRunnerTurn";
import { TaskChatLiveRunPill, toolCountSummaryFromEntries } from "../task-chat/TaskChatLiveRunPill";
import { TaskChatLiveTail } from "../task-chat/TaskChatLiveTail";
import { TaskChatComposer } from "../task-chat/TaskChatComposer";
import { TaskChatComposerDock } from "../task-chat/TaskChatComposerDock";
import { TaskChatProtocolCard } from "../task-chat/TaskChatProtocolCard";
import { TaskChatBubbleActions } from "../task-chat/TaskChatBubbleActions";
import { TaskChatPresentationProvider } from "../task-chat/presentation-mode";
import { parseCssTimeMs } from "../task-chat/motion-tokens";
import { isTerminalRunStatus, paperclipRunnerFinalResponse, transcriptToTaskChatItems } from "../task-chat/transcript-adapter";
import type { TaskChatItem, TaskChatMessageItem, TaskChatRuntimeRequestDecision, TaskChatRuntimeRequestItem } from "../task-chat/task-chat-model";
import { previewHistory, THREAD_PREVIEW_ALIASES, THREAD_PREVIEW_CASES, type ThreadPreviewCase } from "./task-thread-fixtures";
/** Local design sandbox only. Uses the same transport adapter and rendered
* surfaces as TaskChatThread; no lab scenarios or imitation thread markup. */
export default function DesignSandboxTaskThread({
children, header, agentName, agent, defaultConversation, previewMode = "catalog",
}: {
children: ReactNode;
header: ReactNode;
agentName: string;
agent?: Agent;
defaultConversation: boolean;
previewMode?: "catalog" | "playback";
}) {
const [params, setParams] = useSearchParams();
const requested = params.get("threadPreview") ?? (defaultConversation ? "staging-conversation" : "saved");
const aliased = THREAD_PREVIEW_ALIASES[requested] ?? requested;
const selected = aliased === "all" || THREAD_PREVIEW_CASES.some(item => item.id === aliased) ? aliased : "saved";
const [generation, setGeneration] = useState(0);
const { enabled: streamlined } = useStreamlinedUiEnabled();
const viewport = useRef<HTMLDivElement>(null);
const onScroll = useScrollbarWhileScrolling();
const choose = (value: string) => setParams(current => {
const next = new URLSearchParams(current);
next.set("threadPreview", value);
return next;
}, { replace: true });
const cases = selected === "all" ? THREAD_PREVIEW_CASES : THREAD_PREVIEW_CASES.filter(item => item.id === selected);
useEffect(() => { if (viewport.current) viewport.current.scrollTop = 0; }, [selected]);
if (previewMode === "playback" && selected !== "saved") return <TaskChatPresentationProvider mode={streamlined ? "streamlined" : "production"}><ConversationPlayback header={header} agentName={agentName} agent={agent} onShowSavedThread={() => choose("saved")} /></TaskChatPresentationProvider>;
return <div className="flex min-h-0 flex-1 flex-col" data-testid="sandbox-task-thread" data-preview-state={selected}>
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border bg-background py-2 text-xs">
<label htmlFor="sandbox-thread-state" className="font-normal text-foreground">Thread preview</label>
<NativeSelect id="sandbox-thread-state" value={selected} onChange={event => choose(event.target.value)} className="w-auto min-w-0 max-w-full text-xs" aria-label="Thread preview state">
<option value="saved">Saved thread</option>
<option value="all">All states ({THREAD_PREVIEW_CASES.length})</option>
{[...new Set(THREAD_PREVIEW_CASES.map(item => item.group))].map(group => <optgroup key={group} label={group}>
{THREAD_PREVIEW_CASES.filter(item => item.group === group).map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
</optgroup>)}
</NativeSelect>
{selected !== "saved" ? <>
<Button size="sm" variant="outline" onClick={() => setGeneration(value => value + 1)}>Reset / replay</Button>
<Button size="sm" variant="ghost" onClick={() => choose("saved")}>Show saved thread</Button>
<span className="text-muted-foreground">Production renderers · synthetic data · preview actions stay local</span>
</> : null}
</div>
{selected === "saved" ? children : <TaskChatPresentationProvider mode={streamlined ? "streamlined" : "production"}>
<div ref={viewport} onScroll={onScroll} className={`scrollbar-while-scrolling flex min-h-0 flex-1 flex-col ${selected === "all" ? "overflow-y-auto" : ""}`} data-testid="sandbox-thread-viewport">
{selected === "all" ? <div className="mx-auto w-full max-w-(--tc-shell-max-w) pt-3">{header}</div> : null}
{cases.map(example => <PreviewCase key={`${selected}:${example.id}:${generation}`} example={example} header={header} agentName={agentName} agent={agent} replay={selected !== "all"} streamlined={streamlined} />)}
</div>
</TaskChatPresentationProvider>}
</div>;
}
function PreviewCase({ example, header, agentName, agent, replay, streamlined }: {
example: ThreadPreviewCase; header: ReactNode; agentName: string; agent?: Agent; replay: boolean; streamlined: boolean;
}) {
const scenario = useMemo(() => example.build(), [example]);
const startedAt = useRef(Date.now() - 40_000);
const [characters, setCharacters] = useState(replay ? 1 : Infinity);
const [decisions, setDecisions] = useState<Record<string, TaskChatRuntimeRequestDecision>>({});
const [votes, setVotes] = useState<Record<string, FeedbackVoteValue>>({});
const [stopped, setStopped] = useState(false);
const [resumed, setResumed] = useState(false);
const [takeoverDismissed, setTakeoverDismissed] = useState(false);
const [workMode, setWorkMode] = useState<IssueWorkMode>("standard");
const [messages, setMessages] = useState<TaskChatItem[]>([]);
const status = stopped ? "cancelled" : scenario.status;
const terminal = isTerminalRunStatus(status);
const parsed = useMemo(() => transcriptToTaskChatItems(scenario.entries, { runId: `synthetic-${example.id}`, agentName, running: !terminal }), [scenario, example.id, agentName, terminal]);
const streamingLength = parsed.reduce((length, item) => item.kind === "message" && item.streaming ? Math.max(length, wordCount(item.text)) : item.kind === "thinking" && item.streaming ? Math.max(length, wordCount(item.lines.join("\n"))) : length, 0);
useEffect(() => {
if (!replay || !streamingLength) return;
const interval = parseCssTimeMs(getComputedStyle(document.documentElement).getPropertyValue("--motion-sandbox-word-tick"));
if (!interval || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setCharacters(Infinity); return; }
const timer = window.setInterval(() => setCharacters(count => {
if (count >= streamingLength) { window.clearInterval(timer); return Infinity; }
return count + 1;
}), interval);
return () => window.clearInterval(timer);
}, [replay, streamingLength]);
const items = parsed.map(item => {
if (item.kind === "message" && item.streaming) return { ...item, text: revealWords(item.text, characters) };
if (item.kind === "thinking" && item.streaming) return { ...item, lines: [revealWords(item.lines.join("\n"), characters)] };
if (item.kind === "protocol" && item.surface === "runtime_request" && decisions[item.id]) {
const decision = decisions[item.id];
return { ...item, status: "resolved" as const, resolvedAction: decision.action, ...("response" in decision ? { response: decision.response } : {}) };
}
return item;
});
const pending = items.find((item): item is TaskChatRuntimeRequestItem => item.kind === "protocol" && item.surface === "runtime_request" && item.status === "pending");
const runtimeDecision = (item: TaskChatRuntimeRequestItem, decision: TaskChatRuntimeRequestDecision) => setDecisions(current => ({ ...current, [item.id]: decision }));
const history = useMemo(() => {
const prior = previewHistory(agentName);
return [prior.human, ...settledReply({ ...prior.response, agent }, prior.entries, `synthetic-${example.id}-history`)];
}, [agentName, agent, example.id]);
const actions = (item: TaskChatMessageItem) => item.author === "agent" && !item.streaming ? <TaskChatBubbleActions copyText={item.text} feedback={{
activeVote: votes[item.id] ?? null, sharingPreference: "allowed", termsUrl: null,
onVote: async vote => { setVotes(current => ({ ...current, [item.id]: vote })); },
}} /> : null;
const followup: TaskChatMessageItem = { id: "synthetic-followup", kind: "message", author: "human", isCurrentUser: true, text: "Tell me your method for finding this out.", timestamp: "10:12 AM", optimistic: scenario.messageState };
const final = terminal && !scenario.renderer ? paperclipRunnerFinalResponse(parsed, { allowFallback: true }) : undefined;
const failed = ["failed", "timed_out", "cancelled"].includes(status);
const notice: TaskChatItem[] = scenario.notice ? [{ id: "synthetic-notice", kind: "message", author: "system", text: "Synthetic workspace notice. Expand this row to inspect the details.", timestamp: "10:13 AM", presentation: { kind: "system_notice", tone: scenario.notice, title: `Workspace preview · ${scenario.notice}`, detailsDefaultOpen: false, density: "compact" } }] : [];
const historyItems = [...history, followup, ...(final ? settledReply({ ...final, interstitial: false, streaming: false, authorName: agentName, agent, timestamp: "10:13 AM" }, scenario.entries, `synthetic-${example.id}`, failed) : []), ...notice, ...messages];
const showTail = !final && !scenario.paused && !scenario.notice && !scenario.messageState;
const showComposer = replay || Boolean(pending || scenario.paused || scenario.messageState);
const agentMap = useMemo(() => new Map([[agent?.id ?? "synthetic-agent", agent ?? { id: "synthetic-agent", name: agentName }]]), [agent, agentName]);
return <section className={`flex min-w-0 flex-1 flex-col ${replay ? "min-h-0" : "pt-4"}`} data-preview-case={example.id} data-preview-renderer={scenario.renderer ?? "transcript"} aria-label={example.label}>
{!replay ? <h3 className="mx-auto mb-3 w-full max-w-(--tc-shell-max-w) border-t border-border pt-4 text-xs font-normal text-muted-foreground">{example.label}</h3> : null}
<TaskChatThreadView items={historyItems} scroll={replay} header={replay ? header : undefined} className={replay ? "pt-3" : undefined} renderMessageActions={actions} onRuntimeRequestDecision={runtimeDecision} tail={showTail ? <>
{scenario.renderer === "native" ? <TaskChatRunnerTurn
runId={`synthetic-${example.id}`} agentName={agentName} agent={agent} items={items} status={status}
startedAtMs={status === "queued" ? null : startedAt.current} finishedAtMs={terminal ? startedAt.current + 40_000 : null}
activityUnavailable={scenario.activityUnavailable} continuedAfterSteering={scenario.continuedAfterSteering}
onRuntimeRequestDecision={runtimeDecision}
/> : <TaskChatShimmerScope items={items}>
<TaskChatLiveRunPill status={status} startedAtMs={status === "queued" ? null : startedAt.current} finishedAtMs={terminal ? startedAt.current + 40_000 : null} toolSummary={toolCountSummaryFromEntries(scenario.entries)} />
<MarkdownWordStreaming.Provider value={replay}><TaskChatLiveTail items={items} emptyMessage={scenario.emptyMessage ?? "Waiting for transcript…"} onRuntimeRequestDecision={runtimeDecision} /></MarkdownWordStreaming.Provider>
{failed ? <TaskChatThreadView items={[{ id: "synthetic-run-stop", kind: "marker", variant: "interrupted", tone: status === "cancelled" ? "neutral" : "error", collapsible: true, label: status === "cancelled" ? "Run cancelled" : status === "timed_out" ? "Run timed out" : "Run failed", detail: status === "cancelled" ? "The synthetic run was stopped; no saved task was changed." : "Synthetic provider failure: the test process stopped before a final reply was available." }]} scroll={false} /> : null}
</TaskChatShimmerScope>}
</> : null} />
{showComposer ? <TaskChatComposerDock mobile={false} streamlined={streamlined}>
<TaskChatComposer
agentMap={agentMap} modelAgents={agent ? new Map([[agent.id, agent]]) : undefined} currentAssigneeValue={`agent:${agent?.id ?? "synthetic-agent"}`}
enableReassign reassignOptions={[{ id: `agent:${agent?.id ?? "synthetic-agent"}`, label: agentName }]}
workMode={workMode} onWorkModeChange={setWorkMode}
placeholder={`Message ${agentName} — synthetic reply…`}
draftKey={`paperclip:design-sandbox:synthetic-thread:${example.id}`}
onStop={!terminal ? async () => setStopped(true) : undefined}
pause={scenario.paused && !resumed ? { scope: scenario.paused, onResume: async () => setResumed(true) } : null}
takeover={pending && !takeoverDismissed ? {
id: pending.id, label: pending.requestType === "permission" ? "Permission" : "Questions", pendingCount: 1,
content: <TaskChatProtocolCard item={pending} presentation="takeover" onRuntimeRequestDecision={runtimeDecision} />,
onDismiss: () => setTakeoverDismissed(true), onSkip: () => runtimeDecision(pending, { action: "cancel" }),
} : null}
pendingTakeover={pending && takeoverDismissed ? { count: 1, label: "1 pending input", onOpen: () => setTakeoverDismissed(false) } : null}
onAdd={async body => setMessages(current => [...current,
{ id: `local-human-${current.length}`, kind: "message", author: "human", text: body, timestamp: "Just now", isCurrentUser: true },
{ id: `local-agent-${current.length}`, kind: "message", author: "agent", authorName: agentName, agent, text: "Preview reply received. This exchange exists only in your browser; no agent was started.", timestamp: "Just now" },
])}
/>
</TaskChatComposerDock> : null}
</section>;
}
@@ -0,0 +1,13 @@
import { attachSettledTurns, buildTurnSummary, settledRunChildren, transcriptToTaskChatItems } from "../task-chat/transcript-adapter";
import type { TaskChatItem, TaskChatMessageItem, TaskChatTurnItem } from "../task-chat/task-chat-model";
/** Same history primitives as the production controller: the posted reply owns
* its text and the settled transcript lives in the Activity footer. */
export function settledPreviewReply(response: TaskChatMessageItem, entries: Parameters<typeof transcriptToTaskChatItems>[0], runId: string, failed = false, durationMs = 74_000): TaskChatItem[] {
const parsed = transcriptToTaskChatItems(entries, { runId, agentName: response.authorName, running: false });
const turn: TaskChatTurnItem = {
id: `${runId}:turn`, kind: "turn", settled: true, historical: true,
items: settledRunChildren(parsed), summary: buildTurnSummary(entries, { durationMs, failed }),
};
return attachSettledTurns([response, turn], new Map([[turn.id, { agentKey: "synthetic-agent", agentName: response.authorName, parts: [{ entries, durationMs, failed }] }]]));
}
@@ -0,0 +1,221 @@
import { revealWordProgress } from "./word-reveal";
import type { HeartbeatRunEvent } from "@paperclipai/shared";
import type { TranscriptEntry } from "../../adapters";
import { nativeRunEventsToTranscript } from "../transcript/native-run-events";
import type { TaskChatMessageItem } from "../task-chat/task-chat-model";
/** Synthetic transport snapshots, not saved runs or copies of the chat-lab UI. */
export interface ThreadPreviewSnapshot {
entries: TranscriptEntry[];
status: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled";
renderer?: "native";
emptyMessage?: string;
activityUnavailable?: boolean;
paused?: "leaf" | "subtree";
messageState?: "pending" | "queued";
notice?: "neutral" | "info" | "success" | "warning" | "danger";
continuedAfterSteering?: boolean;
}
export interface ThreadPreviewCase {
id: string;
label: string;
group: string;
build: () => ThreadPreviewSnapshot;
}
const ts = (second: number) => new Date(Date.UTC(2026, 9, 15, 17, 12, second)).toISOString();
const event = (seq: number, type: string, payload: Record<string, unknown>, itemId?: string): HeartbeatRunEvent => ({
id: seq, companyId: "synthetic-company", agentId: "synthetic-agent", runId: "synthetic-run", seq,
eventType: type, stream: "system", level: "info", color: null, message: null, createdAt: new Date(ts(seq)),
payload: { prpEvent: {
schema: "paperclip.prp.event.v1", sourceEventId: `synthetic-event-${seq}`, sourceSeq: seq,
sourceInstanceId: "synthetic-runner", sourceKind: "runner", runId: "synthetic-run",
normalizedSessionId: "synthetic-session", eventType: type, schemaVersion: 1, priority: 1,
emittedAt: ts(seq), payload, ...(itemId ? { itemId } : {}),
} },
});
const reasoning = (complete = true, channel = "summary", text = "I’ll check the component alias and file list before explaining how the count was obtained.") => [
event(1, "item.started", { kind: "reasoning", channel, text: "" }, "reasoning-1"),
event(2, "item.delta", { text }, "reasoning-1"),
...(complete ? [event(3, "item.completed", { kind: "reasoning", channel, text }, "reasoning-1")] : []),
];
const commentary = (seq: number, text: string, final = false) => event(seq, "item.completed", {
kind: "assistant_message", channel: final ? "final" : "progress", text,
}, `message-${seq}`);
const tool = (seq: number, name = "read_file", done = true, failed = false, options: { command?: string; result?: string } = {}) => [
event(seq, "item.started", { kind: "dynamicToolCall", item: {
type: "tool_use", id: `tool-${seq}`, name,
input: name === "exec_command" ? { command: options.command ?? "rg --files ui/src/components/ui" } : { path: "ui/components.json" },
} }, `tool-${seq}`),
...(done ? [event(seq + 1, "item.completed", { kind: "dynamicToolCall", item: {
type: "tool_result", id: `tool-${seq}`, tool_use_id: `tool-${seq}`,
result: failed ? "Synthetic command failed: the reconnect test timed out." : options.result ?? (name === "read_file" ? '{ "aliases": { "ui": "@/components/ui" } }' : "26 component source files · three project-specific wrappers"),
is_error: failed,
} }, `tool-${seq}`)] : []),
];
const progressText = "The follow-up asks how I arrived at the count. I’ll check the repository configuration and file list, then explain the method.";
export const SYNTHETIC_FINAL = "## Done\n\nI checked the configured component directory and excluded test files.\n\n- **26 component source files** in `ui/src/components/ui/`.\n- Three are project-specific wrappers; 23 are registry-style components.\n- No code changes were needed.\n\n```sh\nrg --files ui/src/components/ui | rg \"\\.tsx$\"\n```";
const workingEvents = () => [
...reasoning(), commentary(4, progressText), ...tool(5),
commentary(8, "The alias points to `ui/src/components/ui/`. I’m checking the filenames and separating registry components from local wrappers."),
...tool(9, "exec_command", false),
];
const completedEvents = () => [...workingEvents().slice(0, -1), ...tool(9, "exec_command"), commentary(12, SYNTHETIC_FINAL, true)];
const snapshot = (events: HeartbeatRunEvent[], status: ThreadPreviewSnapshot["status"] = "running", extra: Partial<ThreadPreviewSnapshot> = {}): ThreadPreviewSnapshot => ({
entries: nativeRunEventsToTranscript(events), status, ...extra,
});
const example = (id: string, label: string, group: string, build: () => ThreadPreviewSnapshot): ThreadPreviewCase => ({ id, label, group, build });
const direct = (id: string, label: string, group: string, entries: TranscriptEntry[], extra: Partial<ThreadPreviewSnapshot> = {}) => example(id, label, group, () => ({ entries, status: "running", ...extra }));
/** The prior exchange makes each state a realistic conversation, like staging. */
export function previewHistory(agentName: string): { human: TaskChatMessageItem; response: TaskChatMessageItem; entries: TranscriptEntry[] } {
return {
human: { id: "preview-request", kind: "message", author: "human", isCurrentUser: true, text: "How many UI components are in this project?", timestamp: "10:10 AM" },
response: { id: "preview-response", kind: "message", author: "agent", authorName: agentName, text: "Done\n\n- Counted **26 component source files** in `ui/src/components/ui/`.\n- The count excludes test files. Three files are project-specific wrappers.\n- Verified the list directly from the repository; no changes were needed.", timestamp: "10:11 AM", channel: "final" },
entries: nativeRunEventsToTranscript(completedEvents()),
};
}
const permission = (status: "pending" | "resolved" | "expired" | "cancelled", input = false): Extract<TranscriptEntry, { kind: "runtime_request" }> => ({
kind: "runtime_request", ts: ts(15), requestId: `preview-${input ? "question" : "permission"}`, requestKind: input ? "elicitation" : "command_approval",
turnId: "synthetic-turn", requestType: input ? "input" : "permission", status,
prompt: input ? "Which reconnect behavior should I verify?" : "Allow running the focused reconnect tests?",
choices: input ? [] : [{ key: "accept", label: "Allow once" }, { key: "decline", label: "Deny" }], fields: [],
questionSet: input ? { schema: "paperclip.question_set.v1", title: "Choose a reconnect policy", questions: [{
id: "policy", prompt: "Which reconnect policy should I test?", required: true, answerMode: "single_select",
options: [{ id: "bounded", label: "Bounded retry", description: "Stop after three attempts." }, { id: "manual", label: "Manual recovery", description: "Ask before reconnecting." }],
}] } : null,
resolvedAction: status === "resolved" ? "accept" : null,
});
const providerFamilies = ["plan", "tool_execution", "research", "delegation", "model_identity", "context", "artifact", "review", "hook", "memory", "safety", "terminal", "wait", "provider_notice"] as const;
export const THREAD_PREVIEW_CASES: ThreadPreviewCase[] = [
example("staging-conversation", "Conversation · live work (staging layout)", "Conversation", () => snapshot(workingEvents())),
example("session-start", "Starting · waiting for activity", "Conversation", () => snapshot([], "running")),
example("runner-queued", "Queued · waiting to start", "Conversation", () => snapshot([], "queued", { emptyMessage: "Waiting to start…" })),
example("thinking", "Thinking · live reasoning", "Conversation", () => snapshot(reasoning(false))),
...(["summary", "detail", "unknown"] as const).map(channel => example(`reasoning-${channel}`, `Reasoning · ${channel === "unknown" ? "lifecycle only" : channel}`, "Reasoning and tools", () => snapshot(reasoning(false, channel, channel === "unknown" ? "" : undefined)))),
example("tool-call", "Tool · reading files", "Reasoning and tools", () => snapshot([...reasoning(), commentary(4, progressText), ...tool(5, "read_file", false)])),
example("working", "Tool · running a command", "Reasoning and tools", () => snapshot(workingEvents())),
example("tool-completed", "Tool · completed", "Reasoning and tools", () => snapshot([...reasoning(), commentary(4, progressText), ...tool(5)])),
example("tool-failed", "Tool · failed", "Reasoning and tools", () => snapshot([...reasoning(), commentary(4, progressText), ...tool(5, "exec_command", true, true)])),
example("tool-interrupted", "Tool · interrupted", "Reasoning and tools", () => snapshot([...tool(5, "exec_command", false)], "cancelled")),
example("responding", "Commentary · streaming", "Conversation", () => snapshot([
...reasoning(), commentary(4, progressText), ...tool(5), event(8, "item.started", { kind: "assistant_message", channel: "progress", text: "" }, "streaming"),
event(9, "item.delta", { text: "The earlier answer counted files in the directory configured for UI components. I’m checking the exact filenames so the explanation is reproducible." }, "streaming"),
])),
example("streaming-final", "Final answer · streaming", "Conversation", () => snapshot([
...completedEvents().slice(0, -1), event(12, "item.started", { kind: "assistant_message", channel: "final", text: "" }, "final"),
event(13, "item.delta", { text: SYNTHETIC_FINAL }, "final"),
])),
example("completed", "Completed · answer and Activity disclosure", "Conversation", () => snapshot(completedEvents(), "succeeded")),
example("activity-phases", "Completed · multiple activity groups", "Conversation", () => snapshot(completedEvents(), "succeeded")),
...(["failed", "timed_out", "cancelled"] as const).map(status => example(`runner-${status}`, `Run · ${status.replaceAll("_", " ")}`, "Run outcomes", () => snapshot([...reasoning(), ...tool(5, "exec_command", false)], status))),
example("runner-running", "Native runner · live activity", "Native runner", () => snapshot(workingEvents(), "running", { renderer: "native" })),
example("runner-succeeded", "Native runner · completed response", "Native runner", () => snapshot(completedEvents(), "succeeded", { renderer: "native" })),
...(["failed", "timed_out", "cancelled"] as const).map(status => example(`native-${status}`, `Native runner · ${status.replaceAll("_", " ")}`, "Native runner", () => snapshot([...reasoning(), ...tool(5, "exec_command", false), event(15, "run.terminal", { schema: "paperclip.prp.terminal.v1", turnTerminalState: status === "cancelled" ? "cancelled" : "failed", runTerminalState: status === "cancelled" ? "cancelled" : "failed", reportedWorkDisposition: "needs_review", stopReason: { schema: "paperclip.stop_reason.v1", code: "synthetic_provider_stop", message: status === "cancelled" ? "The synthetic run was cancelled." : "The synthetic test process stopped before a final reply." } })], status, { renderer: "native" }))),
example("native-streaming", "Native runner · streaming final response", "Native runner", () => snapshot([...reasoning(), ...tool(5), event(12, "item.started", { kind: "assistant_message", channel: "final", text: "" }, "final"), event(13, "item.delta", { text: SYNTHETIC_FINAL }, "final")], "running", { renderer: "native" })),
example("native-starting", "Native runner · starting", "Native runner", () => snapshot([], "queued", { renderer: "native" })),
example("native-reconnecting", "Native runner · activity unavailable", "Native runner", () => snapshot(workingEvents(), "running", { renderer: "native", activityUnavailable: true })),
example("native-steering", "Native runner · continued after steering", "Native runner", () => snapshot(workingEvents(), "running", { renderer: "native", continuedAfterSteering: true })),
...(["pending", "queued"] as const).map(messageState => example(`message-${messageState}`, `Human message · ${messageState}`, "Messages and composer", () => snapshot([], "queued", { messageState }))),
...(["leaf", "subtree"] as const).map(paused => example(`paused-${paused}`, paused === "leaf" ? "Task paused · draft preserved" : "Subtree paused · draft preserved", "Messages and composer", () => snapshot([], "cancelled", { paused }))),
...(["neutral", "info", "success", "warning", "danger"] as const).map(notice => example(`notice-${notice}`, `System notice · ${notice}`, "Messages and composer", () => snapshot([], "succeeded", { notice }))),
...(["pending", "resolved", "expired", "cancelled"] as const).map(status => direct(`permission-${status}`, `Permission request · ${status}`, "Requests and results", [permission("pending"), ...(status !== "pending" ? [permission(status)] : [])], { emptyMessage: "Waiting for permission…" })),
direct("permission-denied", "Permission request · denied", "Requests and results", [{ ...permission("resolved"), resolvedAction: "decline" }]),
direct("question-text", "Question · free-text input", "Requests and results", [{ ...permission("pending", true), questionSet: null, fields: [{ name: "delay", label: "Reconnect delay", placeholder: "For example, 30 seconds" }] }], { emptyMessage: "Waiting for your answer…" }),
...(["pending", "resolved", "expired", "cancelled"] as const).map(status => direct(`question-${status}`, `Question · ${status}`, "Requests and results", [permission("pending", true), ...(status !== "pending" ? [{ ...permission(status, true), response: status === "resolved" ? { schema: "paperclip.question_response.v1" as const, answers: { policy: { selectedOptionIds: ["bounded"] } } } : null }] : [])], { emptyMessage: "Waiting for your answer…" })),
...(["done", "blocked", "needs_review", "yielded"] as const).map(disposition => direct(`result-${disposition}`, `Run result · ${disposition.replaceAll("_", " ")}`, "Requests and results", [{
kind: "run_result", ts: ts(20), disposition, summary: "The reconnect implementation is ready for review.", objectiveSatisfied: disposition === "done",
verification: [{ commandOrCheck: "Reconnect tests", status: "passed", detail: "12 cases passed" }, { commandOrCheck: "Live provider smoke test", status: "not_run", detail: "No provider is contacted by this preview" }],
remainingWork: disposition === "done" ? [] : [{ description: "Review the recovery message.", blocksCompletion: true }],
blocker: disposition === "blocked" ? { reasonCode: "review_required", unblockAction: "Confirm the recovery behavior.", scope: "current_track" } : null, artifacts: [],
}], { renderer: "native", status: disposition === "yielded" ? "running" : "succeeded" })),
...providerFamilies.map(family => direct(`provider-${family}`, `Provider activity · ${family.replaceAll("_", " ")}`, "Provider and workspace", [{
kind: "provider_activity", ts: ts(10), family, eventType: `synthetic.${family}`, status: "completed", title: `${family.replaceAll("_", " ")} activity`,
summary: "Synthetic provider activity using the shipped transcript contract.", payload: family === "plan" ? { steps: [{ stepId: "read", body: "Inspect the reconnect boundary", status: "completed" }, { stepId: "test", body: "Run focused tests", status: "in_progress" }, { stepId: "review", body: "Review recovery messages", status: "pending" }] } : family === "delegation" ? { children: [{ childId: "reviewer", role: "Test reviewer", status: "completed", summary: "Checked the reconnect cases." }, { childId: "writer", role: "Documentation writer", status: "running", activitySummary: "Updating recovery examples" }] } : family === "tool_execution" ? { name: "pnpm test:run", operation: "execute", transport: "process", output: "PASS reconnect.test.ts\n12 tests passed", outputTruncated: false } : { summary: "Synthetic provider activity details.", message: "No provider or agent is contacted." },
}], { renderer: "native" })),
...(["running", "failed", "interrupted", "informational"] as const).map(status => direct(`provider-status-${status}`, `Provider activity · ${status}`, "Provider and workspace", [{ kind: "provider_activity", ts: ts(10), family: "tool_execution", eventType: "synthetic.tool", status, title: "Running reconnect tests", summary: status === "failed" ? "The reconnect test timed out." : "Checking reconnect behavior.", payload: { name: "pnpm test:run", operation: "execute", transport: "process", output: status === "failed" ? "FAIL reconnect.test.ts\nExpected reconnect after 3 attempts; received timeout." : "Checking reconnect tests…" } }], { renderer: "native" })),
direct("workspace-changes", "Workspace · diff summary", "Provider and workspace", [{
kind: "workspace_change", ts: ts(15), changeSetId: "synthetic-changes", revision: 1, source: "runner_verified", complete: true, patchArtifactRef: null,
totals: { files: 1, additions: 1, deletions: 1 }, files: [{ path: "connectors/reconnect.ts", operation: "modify", previousPath: null, additions: 1, deletions: 1, binary: false, diff: "- const delay = 1000;\n+ const delay = Math.min(attempt * 1000, 30_000);" }],
}], { renderer: "native" }),
direct("workspace-file", "Workspace · code preview", "Provider and workspace", [{
kind: "workspace_file_reference", ts: ts(15), referenceId: "synthetic-file", source: "harness_reported", path: "connectors/reconnect.ts", displayName: "Reconnect implementation", mediaType: "text/typescript", presentation: "code", line: 12,
preview: "export const retryDelay = (attempt: number) => Math.min(attempt * 1000, 30_000);", previewTruncated: false, contentDigest: null,
}], { renderer: "native" }),
direct("live-token-cost", "Usage · run and provider totals", "Provider and workspace", [{ kind: "result", ts: ts(18), text: "", inputTokens: 12000, outputTokens: 1800, cachedTokens: 4000, costUsd: 0.042, subtype: "paperclip_runner_usage", isError: false, errors: [] }, { kind: "result", ts: ts(19), text: "Cumulative provider usage includes earlier turns.", inputTokens: 50000, outputTokens: 6000, cachedTokens: 16000, costUsd: 0.19, subtype: "paperclip_runner_session_usage", isError: false, errors: [] }]),
];
// Keep links shared during the first preview usable without retaining lab-only UI.
export const THREAD_PREVIEW_ALIASES: Record<string, string> = {
"human-message": "staging-conversation", "agent-message": "completed", "responding-burst": "responding",
"running": "working", "diff": "workspace-changes", "awaiting-approval": "permission-pending", "runtime-input": "question-pending",
"interrupted": "runner-cancelled", "refused": "permission-denied", "truncated": "runner-failed", "plan-todo": "provider-plan",
"tool-pending": "tool-call", "tool-in_progress": "working", "tool-rejected": "permission-denied",
};
/** One cumulative, successful conversation for the watchable preview. Timing
* uses units of --motion-sandbox-playback-step; no agent/provider is contacted. */
export const CONVERSATION_PLAYBACK_STAGES = [
{ label: "Starting", units: 0.25 },
{ label: "Thinking", units: 0.5 },
{ label: "Commentary", units: 0.5 },
{ label: "Reading files", units: 0.5 },
{ label: "Reviewing the component list", units: 0.5 },
{ label: "Running a command", units: 0.75 },
{ label: "Writing the answer", units: 1 },
{ label: "Follow-up · queued", units: 0.5 },
{ label: "Planning the verification", units: 0.5 },
{ label: "Clarification · demo answer follows automatically", units: 0.75 },
{ label: "Answered · bounded retry", units: 0.5 },
{ label: "Permission · demo approval follows automatically", units: 0.75 },
{ label: "Running focused tests", units: 0.75 },
{ label: "Test failed · inspecting the timeout", units: 0.75 },
{ label: "Explaining the retry", units: 0.5 },
{ label: "Retrying the focused test", units: 0.75 },
{ label: "Reviewing the file preview and usage", units: 0.75 },
{ label: "Writing the verification result", units: 1 },
] as const;
export const PLAYBACK_FOLLOW_UP_STAGE = 7;
export const PLAYBACK_FOLLOW_UP = "Thanks. Can you also verify the reconnect behavior and show me the relevant code?";
export const PLAYBACK_VERIFICATION_FINAL = "## Verification complete\n\nThe bounded retry behavior passed the focused checks on retry.\n\n- The first test attempt timed out; the second passed.\n- The implementation caps the reconnect delay at 30 seconds.\n- No source files were changed.\n\nThis is synthetic preview data; no command or provider was contacted.";
export function conversationPlaybackSnapshot(stage: number, progress: number): ThreadPreviewSnapshot {
const thought = "I’ll check the component directory configured for this project, then count the source files and exclude tests.";
const update = "I’ll inspect the component alias first, then check the file list. No code changes are needed.";
const next = "The alias points to `ui/src/components/ui/`. I’m separating registry components from local wrappers.";
if (stage === 0) return { entries: [], status: "queued" };
// Burst through whole words, then leave a short reading dwell. Transport
// deltas themselves are not guaranteed to align with word boundaries.
const text = (value: string) => revealWordProgress(value, Math.min(1, progress * 2));
if (stage >= PLAYBACK_FOLLOW_UP_STAGE) {
if (stage === 7) return snapshot([], "queued", { emptyMessage: "Waiting to start…" });
const entries: TranscriptEntry[] = [...THREAD_PREVIEW_CASES.find(item => item.id === "provider-plan")!.build().entries];
if (stage >= 9) entries.push({ ...permission(stage > 9 ? "resolved" : "pending", true), response: stage > 9 ? { schema: "paperclip.question_response.v1", answers: { policy: { selectedOptionIds: ["bounded"] } } } : null });
if (stage >= 11) entries.push(permission(stage > 11 ? "resolved" : "pending"));
const test = (seq: number, done: boolean, failed = false) => tool(seq, "exec_command", done, failed, {
command: "pnpm exec vitest run reconnect.test.ts", result: "PASS reconnect.test.ts · 12 cases passed",
});
if (stage >= 12) entries.push(...nativeRunEventsToTranscript(test(20, stage > 12, true)));
if (stage >= 14) entries.push(...nativeRunEventsToTranscript([commentary(23, stage === 14 ? text("The test process timed out before returning a result. I’ll retry the focused check once and inspect the bounded delay implementation.") : "The test process timed out before returning a result. I’ll retry the focused check once and inspect the bounded delay implementation.")]));
if (stage >= 15) entries.push(...nativeRunEventsToTranscript(test(24, stage > 15)));
if (stage >= 16) entries.push(...THREAD_PREVIEW_CASES.find(item => item.id === "workspace-file")!.build().entries, ...THREAD_PREVIEW_CASES.find(item => item.id === "live-token-cost")!.build().entries);
if (stage >= 17) entries.push(...nativeRunEventsToTranscript([
event(28, "item.started", { kind: "assistant_message", channel: "final", text: "" }, "verification-final"),
event(29, "item.delta", { text: stage === 17 ? text(PLAYBACK_VERIFICATION_FINAL) : PLAYBACK_VERIFICATION_FINAL }, "verification-final"),
...(stage > 17 ? [event(30, "item.completed", { kind: "assistant_message", channel: "final", text: PLAYBACK_VERIFICATION_FINAL }, "verification-final")] : []),
]));
return { entries, status: stage >= CONVERSATION_PLAYBACK_STAGES.length ? "succeeded" : "running" };
}
const events = reasoning(stage > 1, "summary", stage === 1 ? text(thought) : thought);
if (stage >= 2) events.push(commentary(4, stage === 2 ? text(update) : update));
if (stage >= 3) events.push(...tool(5, "read_file", stage > 3));
if (stage >= 4) events.push(commentary(8, stage === 4 ? text(next) : next));
if (stage >= 5) events.push(...tool(9, "exec_command", stage > 5));
if (stage === 6) events.push(
event(12, "item.started", { kind: "assistant_message", channel: "final", text: "" }, "playback-final"),
event(13, "item.delta", { text: text(SYNTHETIC_FINAL) }, "playback-final"),
);
return snapshot(events);
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { revealWordProgress, revealWords, wordCount } from "./word-reveal";
describe("whole-word preview streaming", () => {
it("preserves Markdown, Unicode and whitespace without partial words", () => {
const text = " I’ll inspect **components**.\n\n`ui/src/` is next. ";
expect(wordCount(text)).toBe(6);
expect(revealWords(text, 0)).toBe("");
expect(revealWords(text, 1)).toBe(" I’ll ");
expect(revealWords(text, 3)).toBe(" I’ll inspect **components**.\n\n");
expect(revealWords(text, Infinity)).toBe(text);
const prefixes = Array.from({ length: 101 }, (_, index) => revealWordProgress(text, index / 100));
const completePrefixes = new Set(Array.from({ length: 6 }, (_, index) => revealWords(text, index + 1)));
expect(prefixes.every(prefix => completePrefixes.has(prefix))).toBe(true);
expect(prefixes.at(-1)).toBe(text);
});
it("handles empty text and a single long word", () => {
expect(revealWordProgress("", 0.5)).toBe("");
expect(revealWordProgress("reconnection", 0.2)).toBe("reconnection");
expect(revealWordProgress("one two", 2)).toBe("one two");
});
});
@@ -0,0 +1,14 @@
/** Whole whitespace-delimited words; preserve original Markdown and whitespace. */
export function wordCount(text: string): number {
return [...text.matchAll(/\S+/gu)].length;
}
export function revealWords(text: string, count: number): string {
const matches = [...text.matchAll(/\S+\s*/gu)];
if (count >= matches.length) return text;
if (count <= 0) return "";
const last = matches[Math.floor(count) - 1];
return last ? text.slice(0, last.index + last[0].length) : "";
}
export function revealWordProgress(text: string, progress: number): string {
return revealWords(text, Math.max(1, Math.ceil(wordCount(text) * Math.min(1, Math.max(0, progress)))));
}
@@ -21,6 +21,8 @@ export function PropertySection({
<div className={className} data-property-section="true">
{title ? (
<div
data-slot="property-section-label"
data-streamlined={streamlined || undefined}
className={cn(
streamlined
? "pb-1 font-mono text-(length:--text-nano) font-normal uppercase tracking-wide text-muted-foreground/70"
@@ -102,11 +102,13 @@ export function SidePanelToggleButton({
onToggle,
shortcut,
className,
highlightWhenOpen = true,
}: {
open: boolean;
onToggle: () => void;
shortcut?: string;
className?: string;
highlightWhenOpen?: boolean;
}) {
const label = "Toggle side panel";
return (
@@ -121,7 +123,7 @@ export function SidePanelToggleButton({
onClick={onToggle}
className={cn(
"h-(--side-panel-tab-height) w-(--side-panel-tab-height)",
open && "bg-accent text-accent-foreground",
open && highlightWhenOpen && "bg-accent text-accent-foreground",
className,
)}
>
@@ -11,11 +11,11 @@ function parseCssTimeMs(value: string): number | null {
}
/** Marks a scroll viewport active only while scroll events are arriving. */
export function useScrollbarWhileScrolling(): UIEventHandler<HTMLDivElement> {
export function useScrollbarWhileScrolling<T extends HTMLElement = HTMLDivElement>(): UIEventHandler<T> {
const idleTimerRef = useRef<number | null>(null);
const activeElementRef = useRef<HTMLDivElement | null>(null);
const activeElementRef = useRef<T | null>(null);
const handleScroll = useCallback<UIEventHandler<HTMLDivElement>>((event) => {
const handleScroll = useCallback<UIEventHandler<T>>((event) => {
const element = event.currentTarget;
activeElementRef.current = element;
element.dataset.scrollActive = "true";
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { Link } from "@/lib/router";
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
import { SKILLS_NAVIGATION_HREFS, type SkillsNavigationView } from "@/pages/skills/skills-navigation";
const views = [
{ value: "installed", label: "Installed" },
{ value: "discover", label: "Discover" },
{ value: "authored", label: "My Skills" },
] as const;
/** Route navigation with the canonical tab appearance. Sources stays in the actions menu. */
export function SkillsNavigationTabs({ activeView }: { activeView: SkillsNavigationView }) {
return <nav aria-label="Skills views" data-canonical-tabs="standard" className="canonical-tab-list">
{views.map(view => <Link key={view.value} to={SKILLS_NAVIGATION_HREFS[view.value]}
className="canonical-tab" data-state={activeView === view.value ? "active" : "inactive"}
aria-current={activeView === view.value ? "page" : undefined}>{view.label}</Link>)}
</nav>;
}
/** Standalone Skills pages share navigation beneath their own header controls. */
export function SkillsPageNavigation({ activeView, fallback = null }: { activeView: SkillsNavigationView; fallback?: ReactNode }) {
const { enabled } = useStreamlinedUiEnabled();
return enabled ? <div className="shrink-0 border-b border-border px-3 py-2"><SkillsNavigationTabs activeView={activeView} /></div> : fallback;
}
@@ -0,0 +1,152 @@
import { ToolbarSearch } from "@/components/ToolbarSearch";
import type { ReactNode } from "react";
import { ChevronDown, Compass, FolderSearch, Globe, Pencil, Plus, RefreshCw } from "lucide-react";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
import { GithubIcon } from "@/components/icons/github-icon";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
export type DiscoverySort = "agents" | "stars" | "forks" | "recent" | "alphabetical";
const DISCOVERY_SORT_LABELS: Record<DiscoverySort, string> = {
agents: "Most agents",
stars: "Most stars",
forks: "Most forks",
recent: "Recently updated",
alphabetical: "Alphabetical",
};
const DISCOVERY_SORTS: DiscoverySort[] = ["agents", "stars", "forks", "recent", "alphabetical"];
export interface SkillsToolbarProps {
viewTitle: string;
viewDescription: string;
searchLabel: string;
search: string;
onSearchChange: (value: string) => void;
sort: DiscoverySort;
onSortChange: (value: DiscoverySort) => void;
availableSources: { value: string; label: string }[];
sourceBadgeFilter: string;
setSourceBadgeFilter: (value: string) => void;
onScan: () => void;
scanPending: boolean;
onCreate: () => void;
onBrowseDiscover: () => void;
onImport: () => void;
onImportFromProject: () => void;
folderControl?: ReactNode;
navigation?: ReactNode;
onCreateFolder?: () => void;
selectMode?: boolean;
onToggleSelectMode?: () => void;
}
/** Shared shipped Skills controls. Canonical proposal: Components / Toolbars / Skills. */
export function SkillsToolbar({ viewTitle, viewDescription, searchLabel, search, onSearchChange, sort, onSortChange, availableSources, sourceBadgeFilter, setSourceBadgeFilter, onScan, scanPending, onCreate, onBrowseDiscover, onImport, onImportFromProject, folderControl, onCreateFolder, selectMode, onToggleSelectMode, navigation }: SkillsToolbarProps) {
return (
<div data-canonical-toolbar="skills" role="toolbar" aria-label="Skills controls" className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3">
<div className="w-full">
<h1 className="text-lg font-semibold text-foreground">{viewTitle}</h1>
<p className="text-xs text-muted-foreground">{viewDescription}</p>
</div>
<ToolbarSearch query={search} onQueryChange={onSearchChange} label={searchLabel} placeholder={`${searchLabel}…`} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<span className="text-muted-foreground">Sort</span>
<span>{DISCOVERY_SORT_LABELS[sort]}</span>
<ChevronDown className="ml-1 h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={sort} onValueChange={(value) => onSortChange(value as DiscoverySort)}>
{DISCOVERY_SORTS.map((option) => (
<DropdownMenuRadioItem key={option} value={option}>
{DISCOVERY_SORT_LABELS[option]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{availableSources.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<span className="text-muted-foreground">Source</span>
<span>
{sourceBadgeFilter === "all"
? "All"
: availableSources.find((source) => source.value === sourceBadgeFilter)?.label ?? "All"}
</span>
<ChevronDown className="ml-1 h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={sourceBadgeFilter} onValueChange={setSourceBadgeFilter}>
<DropdownMenuRadioItem value="all">All sources</DropdownMenuRadioItem>
{availableSources.map((source) => (
<DropdownMenuRadioItem key={source.value} value={source.value}>
{source.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="ghost"
size="icon-sm"
onClick={() => onScan()}
disabled={scanPending}
aria-label="Scan project workspaces for skills"
title="Scan project workspaces for skills"
>
<RefreshCw className={cn("h-4 w-4", scanPending && "animate-spin")} />
</Button>
{folderControl}
{onCreateFolder ? (
<Button variant="outline" size="sm" onClick={onCreateFolder}>
<Plus className="mr-1 h-3.5 w-3.5" />
New folder
</Button>
) : null}
{onToggleSelectMode ? (
<Button variant="outline" size="sm" aria-pressed={selectMode} onClick={onToggleSelectMode}>
{selectMode ? "Done" : "Select"}
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="default" data-toolbar-primary>
<Plus className="mr-1 h-3.5 w-3.5" />
New
<ChevronDown className="ml-1 h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={onCreate}>
<Pencil className="mr-2 h-4 w-4" />
Create new skill
</DropdownMenuItem>
<DropdownMenuItem onSelect={onBrowseDiscover}>
<Compass className="mr-2 h-4 w-4" />
Discover skills
</DropdownMenuItem>
<DropdownMenuItem asChild><Link to="/skills/sources/new"><GithubIcon className="mr-2 h-4 w-4" />Import from GitHub</Link></DropdownMenuItem>
<DropdownMenuItem asChild><Link to="/skills/sources">Manage sources</Link></DropdownMenuItem>
<DropdownMenuItem onSelect={onImport}>
<Globe className="mr-2 h-4 w-4" />
Import from path or URL
</DropdownMenuItem>
<DropdownMenuItem onSelect={onImportFromProject}>
<FolderSearch className="mr-2 h-4 w-4" />
Import skills from project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{navigation ? <div className="w-full">{navigation}</div> : null}
</div>
);
}
@@ -83,6 +83,7 @@ export function TaskChatActivityPhase({
<div
className="min-w-0 px-1 text-sm text-foreground/90"
data-testid="task-chat-phase-interstitial"
data-conversation-text={item.interstitial.channel === "final" ? "final" : "progress"}
>
<MarkdownBody softBreaks linkIssueReferences>
{item.interstitial.text}
@@ -219,6 +219,7 @@ function TaskChatBubbleContent({
: Math.max(0, mediaRefs.findIndex((ref) => ref.url === lightboxSrc));
return (
<div
data-conversation-message={isHuman ? "human" : "agent"}
className={cn(
"flex w-full flex-col gap-1",
animateEntry && "tc-enter-bubble",
@@ -240,6 +241,7 @@ function TaskChatBubbleContent({
) : null}
{bodyText.length > 0 ? (
<div
data-conversation-text={isHuman ? (isOwnMessage ? "human" : "progress") : item.channel === "progress" ? "progress" : "final"}
// Stable hook so the TaskChatLab bubble-treatment explorations
// (PAP-501) can scope background/border overrides to the agent
// bubble body without touching the live thread.
@@ -12,6 +12,7 @@ interface TaskChatComposerDockProps {
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) {
return <div
data-testid="task-chat-composer-dock"
data-conversation-typography=""
inert={concealed}
aria-hidden={concealed || undefined}
className={cn(
@@ -6,6 +6,7 @@ import { useSecondTick } from "@/hooks/useSecondTick";
import { formatDurationWords } from "@/lib/issue-chat-messages";
import { isCommandTool } from "@/lib/transcriptPresentation";
import { isTerminalRunStatus } from "@/components/task-chat/transcript-adapter";
import { useTaskChatShimmer } from "./TaskChatShimmer";
/**
* "ran N commands, called M tools" for the live tail's status pill, counted off
@@ -58,6 +59,7 @@ export function TaskChatLiveRunPill({
toolSummary: string | null;
}) {
const active = !isTerminalRunStatus(status);
const shimmer = useTaskChatShimmer("working");
// One shared page-wide ticker drives the live elapsed readout, matching the
// default view's `useLiveElapsed`.
useSecondTick(active && startedAtMs != null);
@@ -76,7 +78,7 @@ export function TaskChatLiveRunPill({
className="flex min-w-0 items-center gap-2 py-2"
data-testid="task-chat-live-run-pill"
>
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
<span className={cn("inline-flex items-center gap-2 text-sm font-medium text-foreground/80", active && !shimmer && "text-muted-foreground")}>
{active ? (
<span className="flex size-5 shrink-0 items-center justify-center">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
@@ -86,7 +88,7 @@ export function TaskChatLiveRunPill({
<span className={cn("h-1.5 w-1.5 rounded-full", (failed || active) ? "bg-muted-foreground/40" : "bg-emerald-500/70")} />
</span>
)}
{active ? <span className={cn("shimmer-text")}>{verb}</span> : verb}
{active ? <span className={cn(shimmer && "shimmer-text")}>{verb}</span> : verb}
</span>
{suffix ? <span className="text-xs text-muted-foreground/60">{suffix}</span> : null}
{toolSummary ? <span className="text-xs text-muted-foreground/40">· {toolSummary}</span> : null}
@@ -7,6 +7,7 @@ import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatThinking } from "./TaskChatThinking";
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
import { buildTurnTimelineRows } from "./transcript-adapter";
import { visibleTranscriptPlaceholder } from "./TaskChatShimmer";
/**
* Live body for legacy runner transcripts. The adapter drops debug plumbing;
@@ -42,8 +43,9 @@ export function TaskChatLiveTail({
.filter((row): row is ReactElement => row != null);
if (rows.length === 0) {
return emptyMessage ? (
<div className="px-1 py-1 text-xs text-muted-foreground/70">{emptyMessage}</div>
const placeholder = visibleTranscriptPlaceholder(emptyMessage);
return placeholder ? (
<div className="px-1 py-1 text-xs text-muted-foreground/70">{placeholder}</div>
) : null;
}
@@ -69,6 +71,7 @@ function renderTailRow(
key={item.id}
className="px-1 text-sm text-foreground/90"
data-testid="task-chat-live-text"
data-conversation-text={item.channel === "final" ? "final" : "progress"}
>
<MarkdownBody softBreaks linkIssueReferences>
{item.text}
@@ -1,6 +1,7 @@
import type { IssueDocument } from "@paperclipai/shared";
import { Lightbulb, Maximize2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTaskChatShimmer } from "./TaskChatShimmer";
import type { TaskChatProviderActivityItem } from "./task-chat-model";
const PLAN_PREVIEW_LINE_COUNT = 3;
@@ -82,6 +83,7 @@ export function TaskChatPlanPreviewCard({
testId = "task-chat-plan-preview",
className,
}: TaskChatPlanPreviewCardProps) {
const shimmer = useTaskChatShimmer(source.kind === "live" ? source.activity.id : "saved-plan");
const revision = planRevision(source);
const content = source.kind === "saved" && source.document
? planPreviewContent(source.document.body)
@@ -99,12 +101,12 @@ export function TaskChatPlanPreviewCard({
aria-hidden
className={cn("h-4 w-4 shrink-0", live && "text-(--status-agent-running)")}
/>
<span className={cn("font-medium", live && "shimmer-text shimmer-text-muted")} data-testid={live ? "task-chat-plan-streaming-status" : undefined}>
<span className={cn("font-medium", live && shimmer && "shimmer-text shimmer-text-muted")} data-testid={live ? "task-chat-plan-streaming-status" : undefined}>
{live ? "Writing plan" : "Plan"}
</span>
{headerDetail ? (
<span
className={cn("text-xs", live && "shimmer-text shimmer-text-muted")}
className="text-xs"
>
· {headerDetail}
</span>
@@ -10,6 +10,7 @@ import {
import { MarkdownBody } from "@/components/MarkdownBody";
import { WorkspaceFileLink } from "@/components/WorkspaceFileLink";
import { cn } from "@/lib/utils";
import { useTaskChatShimmer } from "./TaskChatShimmer";
import type {
TaskChatProtocolDetail,
TaskChatProtocolItem,
@@ -276,6 +277,7 @@ function itemStatus(item: TaskChatProtocolItem): "running" | "completed" | "fail
}
export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolItem }) {
const shimmer = useTaskChatShimmer(item.id);
const [open, setOpen] = useState(false);
const detailId = `task-chat-protocol-activity-${useId().replaceAll(":", "")}`;
const presentation = protocolActivityPresentation(item);
@@ -313,8 +315,10 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
<span className="flex w-5 shrink-0 items-center justify-center">
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-protocol-activity-icon" />
</span>
<span className={cn("shrink-0 font-medium", active && "shimmer-text shimmer-text-muted")}>{label}{active ? "…" : ""}</span>
{presentation.detail ? <span className="task-chat-collapsed-line-fade min-w-0 flex-1 font-mono text-(length:--text-micro)">{presentation.detail}</span> : null}
<span data-conversation-tool-running={active && shimmer && item.surface === "provider_activity" && item.family === "tool_execution" ? "true" : undefined} className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<span className={cn("shrink-0 font-medium", active && shimmer && "shimmer-text shimmer-text-muted")}>{label}{active ? "…" : ""}</span>
{presentation.detail ? <span className="task-chat-collapsed-line-fade min-w-0 flex-1 font-mono text-(length:--text-micro)">{presentation.detail}</span> : null}
</span>
<span className="ml-auto flex shrink-0 items-center gap-2">
{expandable ? (
<ChevronRight
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff. Show more