mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
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:
206 files changed
+13380
-925
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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]; }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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 |",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
@@ -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") });
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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()} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>,
|
||||
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
@@ -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,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} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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")!;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user