From 7b9334cd109d62e8f752f56bf0dc6b5a401a6a28 Mon Sep 17 00:00:00 2001 From: scotttong Date: Mon, 19 Oct 2026 17:45:26 -0700 Subject: [PATCH] Draft: Build agent-ready design proposal system (#16875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Paperclip --- DESIGN.md | 774 +++++++++++ doc/design/CHANGING-THE-UI.md | 12 + maps/feature-map/skills.md | 4 +- maps/storybook-map/README.md | 19 + scripts/storybook-button-usage.mjs | 80 ++ scripts/storybook-button-usage.test.mjs | 25 + scripts/storybook-catalog.mjs | 18 +- scripts/storybook-catalog.test.mjs | 44 +- scripts/storybook-feature-index.mjs | 2 +- scripts/storybook-feature-index.test.mjs | 2 +- .../activity-list-design.spec.ts | 71 + .../storybook-visual/app-shell-design.spec.ts | 244 ++++ tests/storybook-visual/buttons-design.spec.ts | 80 ++ .../color-foundations.spec.ts | 107 ++ tests/storybook-visual/flyout-design.spec.ts | 88 ++ tests/storybook-visual/playwright.config.ts | 4 +- tests/storybook-visual/sidebar-design.spec.ts | 122 ++ tests/storybook-visual/tabs-design.spec.ts | 44 + .../task-hover-card-design.spec.ts | 72 + .../task-list-page-design.spec.ts | 478 +++++++ .../task-list-reproduction-trial.config.ts | 20 + .../task-list-reproduction-trial.spec.ts | 225 +++ .../storybook-visual/task-row-design.spec.ts | 701 ++++++++++ .../storybook-visual/toolbars-design.spec.ts | 171 +++ .../task-list-reproduction-trial.spec.ts | 357 +++++ .../typography-foundations.spec.ts | 412 ++++++ ui/public/fonts/NOTICE.md | 13 + ui/public/fonts/PaperMono-OFL.txt | 93 ++ ui/public/fonts/PaperMono-Variable.woff2 | Bin 0 -> 53276 bytes ui/src/App.tsx | 2 +- ui/src/components/ActivityList.tsx | 16 + ui/src/components/ActivityRow.tsx | 63 +- ui/src/components/AgentAvatar.tsx | 4 +- ui/src/components/AgentsToolbar.tsx | 35 + ui/src/components/CellMarquee.tsx | 149 ++ ui/src/components/CollectionToolbar.tsx | 1 + ui/src/components/DecisionsToolbar.tsx | 17 +- ui/src/components/DesignPageAside.tsx | 134 ++ ui/src/components/DesignPageShell.tsx | 58 + ui/src/components/DesignSandboxSidebar.tsx | 14 + ui/src/components/DesignSidebar.tsx | 59 + .../components/HorizontalScrollViewport.tsx | 15 + ui/src/components/IssueChatThread.test.tsx | 3 +- .../IssueChatThreadSystemNotice.test.tsx | 5 +- ui/src/components/IssueFilterPanel.tsx | 64 + ui/src/components/IssueFiltersPopover.tsx | 15 +- ui/src/components/IssueLinkQuicklook.tsx | 17 +- ui/src/components/IssueProperties.test.tsx | 6 +- ui/src/components/IssueRow.test.tsx | 75 +- ui/src/components/IssueRow.tsx | 323 +++-- ui/src/components/KanbanBoard.tsx | 60 +- ui/src/components/Layout.production.tsx | 18 +- ui/src/components/Layout.test.tsx | 37 +- ui/src/components/Layout.tsx | 39 +- ui/src/components/MarkdownBody.tsx | 5 +- ui/src/components/MarkdownWordStreaming.tsx | 26 + ui/src/components/MetricCard.tsx | 37 +- ui/src/components/PageTabBar.tsx | 13 +- ui/src/components/ProjectsToolbar.tsx | 71 + ui/src/components/PropertiesPanel.test.tsx | 27 + ui/src/components/PropertiesPanel.tsx | 29 +- ui/src/components/RoutinesToolbar.tsx | 106 ++ ui/src/components/SidebarAccountMenu.tsx | 39 +- ui/src/components/SidebarCompanyMenu.tsx | 47 +- ui/src/components/SidebarNavItem.tsx | 3 +- ui/src/components/SidebarSection.tsx | 2 +- ui/src/components/SidebarShell.test.tsx | 25 + ui/src/components/SidebarShell.tsx | 39 +- ui/src/components/StatusIcon.tsx | 8 +- ui/src/components/TabBar.tsx | 38 + ui/src/components/TaskChatThread.test.tsx | 5 +- ui/src/components/TaskChatThread.tsx | 5 +- ui/src/components/TaskList.tsx | 130 ++ ui/src/components/TaskListColumns.tsx | 192 +++ ui/src/components/TaskListToolbar.tsx | 49 + ui/src/components/TaskRowGroup.tsx | 18 + ui/src/components/TaskSummaryList.tsx | 22 + ui/src/components/ToolbarSearch.tsx | 21 + ui/src/components/TruncatedText.tsx | 22 + .../components/artifacts/ArtifactsToolbar.tsx | 86 ++ .../design-sandbox/ConversationPlayback.tsx | 77 ++ .../ConversationPlaybackTurn.tsx | 35 + .../DesignSandboxTaskThread.test.tsx | 172 +++ .../DesignSandboxTaskThread.tsx | 172 +++ .../design-sandbox/settled-preview-reply.ts | 13 + .../design-sandbox/task-thread-fixtures.ts | 221 +++ .../design-sandbox/word-reveal.test.ts | 22 + .../components/design-sandbox/word-reveal.ts | 14 + .../issue-properties/primitives.tsx | 2 + .../components/side-panel/SidePanelFrame.tsx | 4 +- .../use-scrollbar-while-scrolling.ts | 6 +- .../skill-studio/SkillsNavigationTabs.tsx | 25 + .../components/skill-studio/SkillsToolbar.tsx | 152 +++ .../task-chat/TaskChatActivityPhase.tsx | 1 + .../components/task-chat/TaskChatBubble.tsx | 2 + .../task-chat/TaskChatComposerDock.tsx | 1 + .../task-chat/TaskChatLiveRunPill.tsx | 6 +- .../components/task-chat/TaskChatLiveTail.tsx | 7 +- .../task-chat/TaskChatPlanPreviewCard.tsx | 6 +- .../task-chat/TaskChatProtocolActivityRow.tsx | 8 +- .../task-chat/TaskChatRunnerActivityGroup.tsx | 8 +- .../task-chat/TaskChatRunnerTurn.tsx | 24 +- .../task-chat/TaskChatShimmer.test.ts | 34 + .../components/task-chat/TaskChatShimmer.tsx | 40 + .../components/task-chat/TaskChatThinking.tsx | 10 +- .../task-chat/TaskChatThreadView.tsx | 4 +- .../components/task-chat/TaskChatToolCard.tsx | 4 +- .../task-chat/TaskChatTurnHandoff.tsx | 48 + ui/src/components/task-chat/motion-tokens.ts | 9 + ui/src/components/ui/avatar-initials.tsx | 14 + ui/src/components/ui/avatar.tsx | 7 +- ui/src/components/ui/card.tsx | 4 + ui/src/components/ui/dropdown-menu.tsx | 8 +- ui/src/components/ui/flyout-appearance.tsx | 14 + ui/src/components/ui/flyout.tsx | 26 + ui/src/components/ui/popover.tsx | 3 + ui/src/components/ui/resizable-panels.tsx | 2 +- ui/src/context/DesignSandboxContext.test.tsx | 51 + ui/src/context/DesignSandboxContext.tsx | 40 + ui/src/hooks/useHorizontalScrollEdges.ts | 29 + ui/src/hooks/usePaneWidthReset.ts | 30 + ui/src/hooks/useTaskListCollection.test.tsx | 105 ++ ui/src/hooks/useTaskListCollection.ts | 68 + ui/src/index.css | 1206 ++++++++++++++++- ui/src/lib/attention.test.ts | 15 + ui/src/lib/attention.ts | 14 +- ui/src/lib/task-list-views.test.ts | 49 + ui/src/lib/task-list-views.ts | 51 + ui/src/lib/task-list.test.ts | 40 + ui/src/lib/task-list.ts | 55 + ui/src/main.tsx | 3 +- ui/src/pages/AgentDetail.tsx | 5 +- ui/src/pages/Agents.test.tsx | 50 +- ui/src/pages/Agents.tsx | 87 +- ui/src/pages/Artifacts.test.tsx | 2 +- ui/src/pages/Artifacts.tsx | 110 +- ui/src/pages/CompanySkills.test.tsx | 22 +- ui/src/pages/CompanySkills.tsx | 178 +-- ui/src/pages/Dashboard.tsx | 16 +- ui/src/pages/DecisionQueuePage.tsx | 14 +- ui/src/pages/DesignSandboxTasks.tsx | 104 ++ ui/src/pages/IssueDetail.tsx | 68 +- ui/src/pages/Issues.tsx | 5 +- ui/src/pages/Projects.test.tsx | 15 + ui/src/pages/Projects.tsx | 79 +- ui/src/pages/Routines.test.tsx | 52 +- ui/src/pages/Routines.tsx | 140 +- ui/src/pages/SkillSources.test.tsx | 18 +- ui/src/pages/SkillSources.tsx | 2 + ui/src/pages/SkillStudio.tsx | 5 + ui/src/pages/WhatNeedsMe.tsx | 14 +- ui/src/pages/apps/Browse.test.tsx | 12 +- ui/src/pages/apps/Browse.tsx | 21 +- ui/src/pages/apps/CatalogSourceFilters.tsx | 10 +- ui/src/pages/apps/ConnectorsToolbar.tsx | 17 + ui/storybook/.storybook/main.ts | 3 +- ui/storybook/.storybook/preview-base.tsx | 3 +- .../activity-list.stories.tsx | 37 + .../agent-workload-trial.stories.tsx | 115 ++ .../design-proposals/app-shell.stories.tsx | 34 + .../design-proposals/buttons.stories.tsx | 42 + .../design-proposals/colors.stories.tsx | 30 + .../design-proposals/flyout.stories.tsx | 34 + .../project-health-trial.stories.tsx | 79 ++ .../segmented-controllers.stories.tsx | 33 + .../design-proposals/sidebar.stories.tsx | 53 + .../stories/design-proposals/tabs.stories.tsx | 34 + .../task-hover-card.stories.tsx | 35 + .../task-list-page.stories.tsx | 29 + .../task-row-design.stories.tsx | 354 +++++ .../tasks-reproduction-test-2.stories.tsx | 25 + .../tasks-reproduction-test.stories.tsx | 26 + .../design-proposals/toolbars.stories.tsx | 44 + .../design-proposals/typography.stories.tsx | 53 + .../design-proposals/ActivityListPreview.tsx | 75 + .../design-proposals/AgentWorkloadTrial.tsx | 158 +++ .../design-proposals/AppShellPreview.tsx | 103 ++ .../design-proposals/ButtonSamples.tsx | 217 +++ .../design-proposals/ButtonSpecimen.tsx | 58 + .../support/design-proposals/ButtonUsage.tsx | 44 + .../design-proposals/ButtonsPlayground.tsx | 73 + .../design-proposals/ColorsPreview.tsx | 122 ++ .../design-proposals/ConversationTypeSpec.tsx | 146 ++ .../ConversationTypographyPreview.tsx | 68 + .../design-proposals/DesignProposalAccess.tsx | 27 + .../design-proposals/FlyoutPreview.tsx | 63 + .../design-proposals/ProjectHealthTrial.tsx | 160 +++ .../SegmentedControllersPreview.tsx | 70 + .../design-proposals/SidebarPreview.tsx | 46 + .../support/design-proposals/TabsPreview.tsx | 56 + .../design-proposals/TaskHoverCardPreview.tsx | 66 + .../design-proposals/TaskListPagePreview.tsx | 64 + .../TasksReproductionTrial.tsx | 64 + .../TasksReproductionTrial2.tsx | 81 ++ .../design-proposals/ToolbarsPreview.tsx | 148 ++ .../design-proposals/TypographyPreview.tsx | 163 +++ .../agent-workload-trial-data.ts | 84 ++ .../design-proposals/agent-workload-trial.css | 10 + .../design-proposals/button-usage.d.ts | 4 + .../conversation-type-inventory.ts | 91 ++ .../project-health-trial-data.ts | 167 +++ .../design-proposals/task-list-data.ts | 83 ++ .../design-proposals/task-page-data.ts | 16 + .../typography-specification.ts | 81 ++ .../useButtonPreviewStates.ts | 37 + .../support/feature-map/FeatureOverview.tsx | 14 +- 206 files changed, 13380 insertions(+), 925 deletions(-) create mode 100644 scripts/storybook-button-usage.mjs create mode 100644 scripts/storybook-button-usage.test.mjs create mode 100644 tests/storybook-visual/activity-list-design.spec.ts create mode 100644 tests/storybook-visual/app-shell-design.spec.ts create mode 100644 tests/storybook-visual/buttons-design.spec.ts create mode 100644 tests/storybook-visual/color-foundations.spec.ts create mode 100644 tests/storybook-visual/flyout-design.spec.ts create mode 100644 tests/storybook-visual/sidebar-design.spec.ts create mode 100644 tests/storybook-visual/tabs-design.spec.ts create mode 100644 tests/storybook-visual/task-hover-card-design.spec.ts create mode 100644 tests/storybook-visual/task-list-page-design.spec.ts create mode 100644 tests/storybook-visual/task-list-reproduction-trial.config.ts create mode 100644 tests/storybook-visual/task-list-reproduction-trial.spec.ts create mode 100644 tests/storybook-visual/task-row-design.spec.ts create mode 100644 tests/storybook-visual/toolbars-design.spec.ts create mode 100644 tests/storybook-visual/trial-2/task-list-reproduction-trial.spec.ts create mode 100644 tests/storybook-visual/typography-foundations.spec.ts create mode 100644 ui/public/fonts/PaperMono-OFL.txt create mode 100644 ui/public/fonts/PaperMono-Variable.woff2 create mode 100644 ui/src/components/ActivityList.tsx create mode 100644 ui/src/components/AgentsToolbar.tsx create mode 100644 ui/src/components/CellMarquee.tsx create mode 100644 ui/src/components/DesignPageAside.tsx create mode 100644 ui/src/components/DesignPageShell.tsx create mode 100644 ui/src/components/DesignSandboxSidebar.tsx create mode 100644 ui/src/components/DesignSidebar.tsx create mode 100644 ui/src/components/HorizontalScrollViewport.tsx create mode 100644 ui/src/components/IssueFilterPanel.tsx create mode 100644 ui/src/components/MarkdownWordStreaming.tsx create mode 100644 ui/src/components/ProjectsToolbar.tsx create mode 100644 ui/src/components/RoutinesToolbar.tsx create mode 100644 ui/src/components/TabBar.tsx create mode 100644 ui/src/components/TaskList.tsx create mode 100644 ui/src/components/TaskListColumns.tsx create mode 100644 ui/src/components/TaskListToolbar.tsx create mode 100644 ui/src/components/TaskRowGroup.tsx create mode 100644 ui/src/components/TaskSummaryList.tsx create mode 100644 ui/src/components/ToolbarSearch.tsx create mode 100644 ui/src/components/TruncatedText.tsx create mode 100644 ui/src/components/artifacts/ArtifactsToolbar.tsx create mode 100644 ui/src/components/design-sandbox/ConversationPlayback.tsx create mode 100644 ui/src/components/design-sandbox/ConversationPlaybackTurn.tsx create mode 100644 ui/src/components/design-sandbox/DesignSandboxTaskThread.test.tsx create mode 100644 ui/src/components/design-sandbox/DesignSandboxTaskThread.tsx create mode 100644 ui/src/components/design-sandbox/settled-preview-reply.ts create mode 100644 ui/src/components/design-sandbox/task-thread-fixtures.ts create mode 100644 ui/src/components/design-sandbox/word-reveal.test.ts create mode 100644 ui/src/components/design-sandbox/word-reveal.ts create mode 100644 ui/src/components/skill-studio/SkillsNavigationTabs.tsx create mode 100644 ui/src/components/skill-studio/SkillsToolbar.tsx create mode 100644 ui/src/components/task-chat/TaskChatShimmer.test.ts create mode 100644 ui/src/components/task-chat/TaskChatShimmer.tsx create mode 100644 ui/src/components/task-chat/TaskChatTurnHandoff.tsx create mode 100644 ui/src/components/ui/avatar-initials.tsx create mode 100644 ui/src/components/ui/flyout-appearance.tsx create mode 100644 ui/src/components/ui/flyout.tsx create mode 100644 ui/src/context/DesignSandboxContext.test.tsx create mode 100644 ui/src/context/DesignSandboxContext.tsx create mode 100644 ui/src/hooks/useHorizontalScrollEdges.ts create mode 100644 ui/src/hooks/usePaneWidthReset.ts create mode 100644 ui/src/hooks/useTaskListCollection.test.tsx create mode 100644 ui/src/hooks/useTaskListCollection.ts create mode 100644 ui/src/lib/task-list-views.test.ts create mode 100644 ui/src/lib/task-list-views.ts create mode 100644 ui/src/lib/task-list.test.ts create mode 100644 ui/src/lib/task-list.ts create mode 100644 ui/src/pages/DesignSandboxTasks.tsx create mode 100644 ui/src/pages/apps/ConnectorsToolbar.tsx create mode 100644 ui/storybook/stories/design-proposals/activity-list.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/agent-workload-trial.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/app-shell.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/buttons.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/colors.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/flyout.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/project-health-trial.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/segmented-controllers.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/sidebar.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/tabs.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/task-hover-card.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/task-list-page.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/task-row-design.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/tasks-reproduction-test-2.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/tasks-reproduction-test.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/toolbars.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/typography.stories.tsx create mode 100644 ui/storybook/support/design-proposals/ActivityListPreview.tsx create mode 100644 ui/storybook/support/design-proposals/AgentWorkloadTrial.tsx create mode 100644 ui/storybook/support/design-proposals/AppShellPreview.tsx create mode 100644 ui/storybook/support/design-proposals/ButtonSamples.tsx create mode 100644 ui/storybook/support/design-proposals/ButtonSpecimen.tsx create mode 100644 ui/storybook/support/design-proposals/ButtonUsage.tsx create mode 100644 ui/storybook/support/design-proposals/ButtonsPlayground.tsx create mode 100644 ui/storybook/support/design-proposals/ColorsPreview.tsx create mode 100644 ui/storybook/support/design-proposals/ConversationTypeSpec.tsx create mode 100644 ui/storybook/support/design-proposals/ConversationTypographyPreview.tsx create mode 100644 ui/storybook/support/design-proposals/DesignProposalAccess.tsx create mode 100644 ui/storybook/support/design-proposals/FlyoutPreview.tsx create mode 100644 ui/storybook/support/design-proposals/ProjectHealthTrial.tsx create mode 100644 ui/storybook/support/design-proposals/SegmentedControllersPreview.tsx create mode 100644 ui/storybook/support/design-proposals/SidebarPreview.tsx create mode 100644 ui/storybook/support/design-proposals/TabsPreview.tsx create mode 100644 ui/storybook/support/design-proposals/TaskHoverCardPreview.tsx create mode 100644 ui/storybook/support/design-proposals/TaskListPagePreview.tsx create mode 100644 ui/storybook/support/design-proposals/TasksReproductionTrial.tsx create mode 100644 ui/storybook/support/design-proposals/TasksReproductionTrial2.tsx create mode 100644 ui/storybook/support/design-proposals/ToolbarsPreview.tsx create mode 100644 ui/storybook/support/design-proposals/TypographyPreview.tsx create mode 100644 ui/storybook/support/design-proposals/agent-workload-trial-data.ts create mode 100644 ui/storybook/support/design-proposals/agent-workload-trial.css create mode 100644 ui/storybook/support/design-proposals/button-usage.d.ts create mode 100644 ui/storybook/support/design-proposals/conversation-type-inventory.ts create mode 100644 ui/storybook/support/design-proposals/project-health-trial-data.ts create mode 100644 ui/storybook/support/design-proposals/task-list-data.ts create mode 100644 ui/storybook/support/design-proposals/task-page-data.ts create mode 100644 ui/storybook/support/design-proposals/typography-specification.ts create mode 100644 ui/storybook/support/design-proposals/useButtonPreviewStates.ts diff --git a/DESIGN.md b/DESIGN.md index 376e563dc8d..cce5386f620 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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:` / `agent:`), 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 diff --git a/doc/design/CHANGING-THE-UI.md b/doc/design/CHANGING-THE-UI.md index 77602f0ae18..9a727fb9e10 100644 --- a/doc/design/CHANGING-THE-UI.md +++ b/doc/design/CHANGING-THE-UI.md @@ -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: diff --git a/maps/feature-map/skills.md b/maps/feature-map/skills.md index 532f101202e..7b9c2a169b5 100644 --- a/maps/feature-map/skills.md +++ b/maps/feature-map/skills.md @@ -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. diff --git a/maps/storybook-map/README.md b/maps/storybook-map/README.md index 40d10b62213..92d934175bc 100644 --- a/maps/storybook-map/README.md +++ b/maps/storybook-map/README.md @@ -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`. diff --git a/scripts/storybook-button-usage.mjs b/scripts/storybook-button-usage.mjs new file mode 100644 index 00000000000..63234927a05 --- /dev/null +++ b/scripts/storybook-button-usage.mjs @@ -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]; } + }, + }; +} diff --git a/scripts/storybook-button-usage.test.mjs b/scripts/storybook-button-usage.test.mjs new file mode 100644 index 00000000000..d63699351be --- /dev/null +++ b/scripts/storybook-button-usage.test.mjs @@ -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 = () => <> + + } +
+ +
+ +
+ +
+ ; +} diff --git a/ui/src/components/CellMarquee.tsx b/ui/src/components/CellMarquee.tsx new file mode 100644 index 00000000000..8ab3b7084ce --- /dev/null +++ b/ui/src/components/CellMarquee.tsx @@ -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(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(null); + const onMouseMove = (event: ReactMouseEvent) => { + const cell = Array.from(event.currentTarget.querySelectorAll("[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(null); + const contentRef = useRef(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('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('a, button, [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'); + const owner = control ?? viewport.closest("[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 ( + 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); }} + > + + setRevealComplete(false)} + onTransitionEnd={event => { if (event.propertyName === "transform") setRevealComplete(true); }} + onTransitionCancel={() => setRevealComplete(false)} + >{children} + + + ); +} diff --git a/ui/src/components/CollectionToolbar.tsx b/ui/src/components/CollectionToolbar.tsx index d38369dd055..e1cf96ce7f0 100644 --- a/ui/src/components/CollectionToolbar.tsx +++ b/ui/src/components/CollectionToolbar.tsx @@ -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, diff --git a/ui/src/components/DecisionsToolbar.tsx b/ui/src/components/DecisionsToolbar.tsx index 1390468d56a..9a38191d505 100644 --- a/ui/src/components/DecisionsToolbar.tsx +++ b/ui/src/components/DecisionsToolbar.tsx @@ -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 = { }; interface DecisionsToolbarProps { + query: string; + onQueryChange: (query: string) => void; /** Number of decisions currently shown, for the count pill. */ visibleCount: number; filterOptions: ReturnType; @@ -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 ( -
- {visibleCount > 0 && ( - - {visibleCount} {visibleCount === 1 ? "decision" : "decisions"} - - )} +
+ {/* Filter */} @@ -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" > @@ -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" > @@ -144,6 +148,7 @@ export function DecisionsToolbar({
+ {visibleCount} {visibleCount === 1 ? "decision" : "decisions"}
); } diff --git a/ui/src/components/DesignPageAside.tsx b/ui/src/components/DesignPageAside.tsx new file mode 100644 index 00000000000..a7aeb965fcf --- /dev/null +++ b/ui/src/components/DesignPageAside.tsx @@ -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(null); + const [width, setWidth] = useState(() => 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(":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) => { + 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) => { + 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) => { + // 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 ; +} diff --git a/ui/src/components/DesignPageShell.tsx b/ui/src/components/DesignPageShell.tsx new file mode 100644 index 00000000000..9e2d8e9f7f3 --- /dev/null +++ b/ui/src/components/DesignPageShell.tsx @@ -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(); + const handleAsideScroll = useScrollbarWhileScrolling(); + const hasRightSidebar = rightSidebar != null && rightSidebar !== false; + const maximized = hasRightSidebar && rightSidebarMaximized; + return ( +
+ Skip to main content + {sidebar} +
+

{title}

{headerActions &&
{headerActions}
}
+
{children}
+
+ {hasRightSidebar && +
+

{rightSidebarTitle}

+ {rightSidebarHeader &&
{rightSidebarHeader}
} + {rightSidebarControls &&
{rightSidebarControls}
} +
+
{rightSidebar}
+
} +
+ ); +} diff --git a/ui/src/components/DesignSandboxSidebar.tsx b/ui/src/components/DesignSandboxSidebar.tsx new file mode 100644 index 00000000000..bb52b1a1f13 --- /dev/null +++ b/ui/src/components/DesignSandboxSidebar.tsx @@ -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 openNewIssue()} />; +} diff --git a/ui/src/components/DesignSidebar.tsx b/ui/src/components/DesignSidebar.tsx new file mode 100644 index 00000000000..7489c4bed19 --- /dev/null +++ b/ui/src/components/DesignSidebar.tsx @@ -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; + +/** 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 ( + +
+
+ + Search tasks, agents, projects +
+ +
+
+
+ ); +} diff --git a/ui/src/components/HorizontalScrollViewport.tsx b/ui/src/components/HorizontalScrollViewport.tsx new file mode 100644 index 00000000000..9866c76a743 --- /dev/null +++ b/ui/src/components/HorizontalScrollViewport.tsx @@ -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 ( +
+
+
{children}
+
+
+ ); +} diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index fa5ec8668c2..8fe510106ac 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -177,7 +177,8 @@ vi.mock("./InlineEntitySelector", () => ({ ), })); -vi.mock("./Identity", () => ({ +vi.mock("./Identity", async importOriginal => ({ + ...await importOriginal(), Identity: ({ name }: { name: string }) => {name}, })); diff --git a/ui/src/components/IssueChatThreadSystemNotice.test.tsx b/ui/src/components/IssueChatThreadSystemNotice.test.tsx index 41a6e9a2a67..ab049d24236 100644 --- a/ui/src/components/IssueChatThreadSystemNotice.test.tsx +++ b/ui/src/components/IssueChatThreadSystemNotice.test.tsx @@ -31,7 +31,10 @@ vi.mock("./MarkdownEditor", () => ({ })); vi.mock("./InlineEntitySelector", () => ({ InlineEntitySelector: () => null })); -vi.mock("./Identity", () => ({ Identity: ({ name }: { name: string }) => {name} })); +vi.mock("./Identity", async importOriginal => ({ + ...await importOriginal(), + Identity: ({ name }: { name: string }) => {name}, +})); vi.mock("./OutputFeedbackButtons", () => ({ OutputFeedbackButtons: () => null })); vi.mock("@/components/ui/tooltip", () => ({ Tooltip: ({ children }: { children: ReactNode }) => <>{children}, diff --git a/ui/src/components/IssueFilterPanel.tsx b/ui/src/components/IssueFilterPanel.tsx new file mode 100644 index 00000000000..8dd1c3153de --- /dev/null +++ b/ui/src/components/IssueFilterPanel.tsx @@ -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, "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 ( +
+

{title}

+
+ {searchLabel ?
onQueryChange?.(event.target.value)} />
: null} +
{children}
+
+
+ ); +} + +function FilterRow({ label, checked, onToggle, icon }: { label: string; checked: boolean; onToggle: () => void; icon?: ReactNode }) { + return ; +} + +/** 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: }] : []), ...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 <> + Filters{activeFilterCount ? : null} +
+ + {issueStatusOrder.map(status => onChange({ statuses: toggleIssueFilterValue(state.statuses, status) })} icon={} />)} + + + {visibleProjects.map(project => onChange({ projects: toggleIssueFilterValue(state.projects, project.id) })} />)} + {!visibleProjects.length ?

{queries.projects ? "No projects match." : "No projects available."}

: null} +
+ + {visibleCreators.map(creator => onChange({ creators: toggleIssueFilterValue(state.creators, creator.id) })} icon={creator.kind === "agent" ? : } />)} + {!visibleCreators.length ?

{queries.creators ? "No creators match." : "No creators available."}

: null} +
+ + {visibleAssignees.map(assignee => onChange({ assignees: toggleIssueFilterValue(state.assignees, assignee.id) })} icon={assignee.icon} />)} + {!visibleAssignees.length ?

No assignees match.

: null} +
+ + {visibleLabels.map(label => onChange({ labels: toggleIssueFilterValue(state.labels, label.id) })} icon={} />)} + {!visibleLabels.length ?

{queries.labels ? "No labels match." : "No labels available."}

: null} +
+
+ ; +} diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 97b82dfbea3..792930368dc 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -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 = ( -
+ {refreshed ? :
Filters {activeFilterCount > 0 ? ( @@ -532,8 +536,9 @@ export function IssueFiltersPopover({
- + }
); + return refreshed ? {popover} : popover; } diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index 3f08cad4cc6..dc0fcbf02e4 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -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 ( -
-
-
+
+
+
- + {issue.identifier ?? issue.id.slice(0, 8)} - {projectName ? ( + {projectName && appearance !== "refreshed" ? ( <> ) : null}
- + {timeAgo(new Date(issue.updatedAt))}
{description ? ( -

+

{description}

) : null} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 44ac2de0a1d..c7c4ac1c1fe 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -131,7 +131,8 @@ vi.mock("../lib/assignees", () => ({ }, })); -vi.mock("./StatusIcon", () => ({ +vi.mock("./StatusIcon", async importOriginal => ({ + ...await importOriginal(), StatusIcon: ({ status, blockerAttention, className, glyphContainerClassName, size }: { status: string; blockerAttention?: Issue["blockerAttention"]; className?: string; glyphContainerClassName?: string; size?: string }) => ( {status} ), @@ -141,7 +142,8 @@ vi.mock("./PriorityIcon", () => ({ PriorityIcon: ({ priority }: { priority: string }) => {priority}, })); -vi.mock("./Identity", () => ({ +vi.mock("./Identity", async importOriginal => ({ + ...await importOriginal(), Identity: ({ name, shape }: { name: string; shape?: string }) => {name}, })); diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 188ece5f703..6727f3a78f6 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -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 }) => ( { 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( + + Expand subtasks} + onArchive={onArchive} + /> + , + ); + }); + + const link = container.querySelector("[data-inbox-issue-link]"); + const expand = container.querySelector('button:not([aria-label])'); + const archive = container.querySelector('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); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index f98b1e6edb7..9bbf76d55a5 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -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 = (
+ ); } diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 9b1c19f40ec..1dfd808a010 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -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) => 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(null); @@ -455,6 +459,28 @@ export function KanbanBoard({ // Could be used for visual feedback; keeping simple for now } + const columns = boardStatuses.map((status) => ( + 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 ( -
- {boardStatuses.map((status) => ( - 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, - }, - }; - }); - }} - /> - ))} -
+ {showOverflowFades + ? {columns} + :
{columns}
} {activeIssue ? ( diff --git a/ui/src/components/Layout.production.tsx b/ui/src/components/Layout.production.tsx index 9ef6dddd4b7..56afb99fc06 100644 --- a/ui/src/components/Layout.production.tsx +++ b/ui/src/components/Layout.production.tsx @@ -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() { ) : ( -
- -
- + {designSandbox ? : <> +
+ +
+ + }
)} diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 733d537a3fc..343e5f1a25e 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -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: () =>
Proposed sidebar
})); + 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(); }); + 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(); }); + 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({ diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index eb4a3be13ad..f20ee8fc6e1 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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 }) { ) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? ( - ) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "skills" ? ( - ) : 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 }) { ) : ( -
- {replacesPrimarySidebar ? ( - {secondarySidebar} - ) : ( - {sidebarSections} - )} -
- + {designSandbox ? : <> +
+ {replacesPrimarySidebar ? ( + {secondarySidebar} + ) : ( + {sidebarSections} + )} +
+ + }
)} - {!isMobile && showsAdjacentSecondarySidebar && !keepsPrimarySidebar ? ( + {!isMobile && ((showsAdjacentSecondarySidebar && !keepsPrimarySidebar) || (designSandbox && replacesPrimarySidebar)) ? ( {secondarySidebar} diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 32a518bc78e..00479529869 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -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({ > diff --git a/ui/src/components/MarkdownWordStreaming.tsx b/ui/src/components/MarkdownWordStreaming.tsx new file mode 100644 index 00000000000..6bc3db37157 --- /dev/null +++ b/ui/src/components/MarkdownWordStreaming.tsx @@ -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; 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); + }; +} diff --git a/ui/src/components/MetricCard.tsx b/ui/src/components/MetricCard.tsx index 38a2f9d1d14..4be5e2d1315 100644 --- a/ui/src/components/MetricCard.tsx +++ b/ui/src/components/MetricCard.tsx @@ -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 = ( -
-
-
-

- {value} -

-

- {label} -

- {description && ( -
{description}
- )} -
- + const content = ( +
+
+

+ {value} +

+

+ {label} +

+ {description && ( +
{description}
+ )}
+
); + 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" + ? {content} + :
{content}
; if (to) { return ( diff --git a/ui/src/components/PageTabBar.tsx b/ui/src/components/PageTabBar.tsx index a0f5148880d..db51e9bd2b7 100644 --- a/ui/src/components/PageTabBar.tsx +++ b/ui/src/components/PageTabBar.tsx @@ -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 ( - - {items.map((item) => ( - - {item.label} - - ))} - + ); } diff --git a/ui/src/components/ProjectsToolbar.tsx b/ui/src/components/ProjectsToolbar.tsx new file mode 100644 index 00000000000..dae5d594778 --- /dev/null +++ b/ui/src/components/ProjectsToolbar.tsx @@ -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
+ + + + + + +
+ {PROJECT_SORT_OPTIONS.map((option) => ( + + ))} +
+
+
+
+ +
+
; +} diff --git a/ui/src/components/PropertiesPanel.test.tsx b/ui/src/components/PropertiesPanel.test.tsx index e478d654f6f..c5d1ebc0794 100644 --- a/ui/src/components/PropertiesPanel.test.tsx +++ b/ui/src/components/PropertiesPanel.test.tsx @@ -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")!; diff --git a/ui/src/components/PropertiesPanel.tsx b/ui/src/components/PropertiesPanel.tsx index 783ec1dd393..babd7bed3c0 100644 --- a/ui/src/components/PropertiesPanel.tsx +++ b/ui/src/components/PropertiesPanel.tsx @@ -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 ( @@ -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} diff --git a/ui/src/components/RoutinesToolbar.tsx b/ui/src/components/RoutinesToolbar.tsx new file mode 100644 index 00000000000..084810b6a9d --- /dev/null +++ b/ui/src/components/RoutinesToolbar.tsx @@ -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
+ + + + + + +
+ {([ + ["updated", "Updated"], + ["created", "Created"], + ["lastRun", "Last run"], + ["title", "Title"], + ] as const).map(([field, label]) => ( + + ))} +
+
+
+ + + + + +
+ {([ + ["folder", "Folder"], + ["project", "Project"], + ["assignee", "Agent"], + ["none", "None"], + ] as const).map(([value, label]) => ( + + ))} +
+
+
+
+ {onToggleSelect && } + {runsHref && } + {onNewFolder && } + +
+
; +} diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index b87beea3610..f1592b3947e 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -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({ - {label} + {label} ); @@ -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 ? : null} {initials} - {displayName} + {displayName} - {/* The profile link is a stretched overlay so the staging SHA anchor can sit beside the email without nesting anchors. */} -
+ -
+ - + {signOutMutation.isPending ? "Signing out..." : "Sign out"} - + ) : null} -
- + + {!rail && !isCloud ? ( diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx index 3a9e95f5337..c1072143bcf 100644 --- a/ui/src/components/SidebarCompanyMenu.tsx +++ b/ui/src/components/SidebarCompanyMenu.tsx @@ -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 ( - + {company.name} - + {isEditing ? null : ( - + {company.issuePrefix} - + )} {isEditing ? ( @@ -144,15 +148,15 @@ function SortableCompanyItem({ ); } -export function SidebarCompanyMenu(props: SidebarCompanyMenuProps = {}) { +export function SidebarCompanyMenu({ showChevron, ...props }: SidebarCompanyMenuProps = {}) { return ( - + ); } -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. */} - {currentName ?? `Select ${switcherNoun}`} - + - {!rail && } + {!rail && showChevron && } -
+ Organizations @@ -311,8 +316,8 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa > {isEditingOrder ? "Done" : "Edit"} -
-
+ + No organizations ) ) : null} -
-
+ + {!companyCreationManaged && ( - Create organization + Create organization )} {showInvitePeople ? ( @@ -385,9 +390,9 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa - + {currentName ? `Invite people to ${currentName}` : "Invite people"} - + ) : null} @@ -400,12 +405,12 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa - + {signOutMutation.isPending ? "Signing out..." : "Sign out"} - + ) : null} -
+
); diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx index efbcfa4e56f..1542270e638 100644 --- a/ui/src/components/SidebarNavItem.tsx +++ b/ui/src/components/SidebarNavItem.tsx @@ -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({ )} ) : null} - {label} + {label} {!rail && trailing} {!rail && textBadge && ( {label}; + const headerContent = {label}; const HeaderActionIcon = headerAction?.icon; const headingControl = hasMenu ? ( diff --git a/ui/src/components/SidebarShell.test.tsx b/ui/src/components/SidebarShell.test.tsx index b4571b81952..97163cd941c 100644 --- a/ui/src/components/SidebarShell.test.tsx +++ b/ui/src/components/SidebarShell.test.tsx @@ -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(
Sidebar
); + }); + 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( diff --git a/ui/src/components/SidebarShell.tsx b/ui/src/components/SidebarShell.tsx index 9834434c0bf..fb67580ebc6 100644 --- a/ui/src/components/SidebarShell.tsx +++ b/ui/src/components/SidebarShell.tsx @@ -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(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) => { 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 ( -
+
commitWidth(DEFAULT_SIDEBAR_WIDTH, true)} onKeyDown={handleKeyDown} >
{ + 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 + {items.map(item => {item.label})} + ; +} + +/** Same two visual sizes for collection filters; radio choices are not panels. */ +export function TabFilters({ items, value, onValueChange, size = "standard", label, className }: { + items: readonly TabBarItem[]; + value: T; + onValueChange: (value: T) => void; + size?: TabSize; + label: string; + className?: string; +}) { + return { if (next) onValueChange(next as T); }} data-canonical-tabs={size} aria-label={label} className={cn("canonical-tab-list", className)}> + {items.map(item => {item.label})} + ; +} diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 90ab8e828b7..42faadcedfa 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -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", () => { diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 3c95db55326..40dced32113 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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) { } /> ) : ( - <> + - + )}
) : null} diff --git a/ui/src/components/TaskList.tsx b/ui/src/components/TaskList.tsx new file mode 100644 index 00000000000..8b040b782f1 --- /dev/null +++ b/ui/src/components/TaskList.tsx @@ -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(); + 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; + onCollapsedIdsChange?: (ids: ReadonlySet) => void; + groupBy?: TaskListGrouping; + emptyState?: ReactNode; +}) { + const [columns, setColumns] = useState(initialColumns); + const [columnOrder, setColumnOrder] = useState(allTaskListColumns); + const [sort, setSort] = useState(); + const [localCollapsed, setLocalCollapsed] = useState>(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 ( + + 0 && children.length > 0} + leadingControl={children.length ? ( + + ) : undefined} + columnValues={{ + createdBy: item.createdBy, + assignee: item.assignee ?? "Unassigned", + project: issue.project?.name, + parentTask: parent ? {parent.identifier} {parent.title} : undefined, + tags: issue.labels?.length ? {issue.labels.map(label => {label.name})} : undefined, + worktree: branch ? {branch} : undefined, + lastUpdated: timeAgo(issue.updatedAt), + }} + /> + {expanded ? children.map(child => renderBranch(child, depth + 1)) : null} + + ); + }; + const groups = new Map(); + 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 ( + setSort(undefined)} />}> + + {items.length === 0 ? emptyState : ( +
+ {[...groups].map(([label, groupRoots]) => ( + + {label ? {groupRoots.length}} /> : null} + {groupRoots.map(issue => {renderBranch(issue, 0)})} + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/components/TaskListColumns.tsx b/ui/src/components/TaskListColumns.tsx new file mode 100644 index 00000000000..d1dd128f528 --- /dev/null +++ b/ui/src/components/TaskListColumns.tsx @@ -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 ( + +
+
+
{children}
+
+ {controls ?
{controls}
: null} +
+
+ ); +} + +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, 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 ( + + {onSortChange || onColumnOrderChange ? ( + + ) : label} + + ); +} + +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 ( + { + 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." } }}> + Drag to reorder columns, or use Alt plus Left or Right Arrow. Click to sort. + +
+ {!columns.includes("task") ? : null} + {columns.map(id => column.id === id)!.label} start={taskListColumnStart(columns, id)} sort={sort} onSortChange={onSortChange} onColumnOrderChange={onColumnOrderChange} helpId={helpId} />)} +
+
+
+ ); +} + +/** 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 ( + + + + + + Columns + + {order.map(id => ( + event.preventDefault()} + onCheckedChange={checked => onVisibleColumnsChange(order.filter(column => column === id ? checked === true : visibleColumns.includes(column)))} + > + {taskListColumns.find(column => column.id === id)!.label} + + ))} + { event.preventDefault(); onColumnOrderChange?.(allTaskListColumns); onVisibleColumnsChange(defaultTaskListColumns); onResetDefaults?.(); }}>Reset defaults + + + + ); +} + +export function TaskRowColumnCells({ identifier, values }: { identifier: string; values: TaskRowColumnValues }) { + const columns = useTaskListColumns(); + if (!detailColumns.some(column => columns.includes(column.id))) return null; + return ( + + {columns.filter(id => id !== "task").map(id => ( + + {id === "taskId" ? identifier : values[id] ?? "—"} + + ))} + + ); +} diff --git a/ui/src/components/TaskListToolbar.tsx b/ui/src/components/TaskListToolbar.tsx new file mode 100644 index 00000000000..6432a888f3d --- /dev/null +++ b/ui/src/components/TaskListToolbar.tsx @@ -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; + layout: "list" | "board"; + onLayoutChange: (layout: "list" | "board") => void; + allExpanded: boolean; + onToggleExpanded: () => void; + onNewTask: () => void; + filters: ComponentProps; +}) { + return ( +
+ + + + Views + onViewChange(value as TaskListView)}> + {views.map(option => {option.label})} + + + + +
+ {[{ value: "list", label: "List view", icon: List }, { value: "board", label: "Board view", icon: Columns3 }].map(({ value, label, icon: Icon }) => ( + {label} + ))} +
+
+ {allExpanded ? "Collapse all tasks" : "Expand all tasks"} +
+ +
+
+ ); +} diff --git a/ui/src/components/TaskRowGroup.tsx b/ui/src/components/TaskRowGroup.tsx new file mode 100644 index 00000000000..8b416a03c8e --- /dev/null +++ b/ui/src/components/TaskRowGroup.tsx @@ -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 ( +
+ ); +} diff --git a/ui/src/components/TaskSummaryList.tsx b/ui/src/components/TaskSummaryList.tsx new file mode 100644 index 00000000000..5a6ab7df0f1 --- /dev/null +++ b/ui/src/components/TaskSummaryList.tsx @@ -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
    + {issues.map(issue =>
  • + +
  • )} +
; +} diff --git a/ui/src/components/ToolbarSearch.tsx b/ui/src/components/ToolbarSearch.tsx new file mode 100644 index 00000000000..d8be76b76a0 --- /dev/null +++ b/ui/src/components/ToolbarSearch.tsx @@ -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; + inputType?: "text" | "search"; +}) { + return
+ + onQueryChange(event.target.value)} /> + {query && } +
; +} diff --git a/ui/src/components/TruncatedText.tsx b/ui/src/components/TruncatedText.tsx new file mode 100644 index 00000000000..611fb7ccf5f --- /dev/null +++ b/ui/src/components/TruncatedText.tsx @@ -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 {children}; +} + +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 {marquee + ? {children} + : children}; +} diff --git a/ui/src/components/artifacts/ArtifactsToolbar.tsx b/ui/src/components/artifacts/ArtifactsToolbar.tsx new file mode 100644 index 00000000000..0a3c65dff5f --- /dev/null +++ b/ui/src/components/artifacts/ArtifactsToolbar.tsx @@ -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 ; +} + +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 ( +
+
+ + + + + + + + Group by + {ARTIFACT_GROUP_OPTIONS.map((option) => ( + selectGroupBy(option.value)} + className="justify-between" + > + {option.label} + {groupBy === option.value ? : null} + + ))} + + +
+ +
+ ); +} diff --git a/ui/src/components/design-sandbox/ConversationPlayback.tsx b/ui/src/components/design-sandbox/ConversationPlayback.tsx new file mode 100644 index 00000000000..714edb917eb --- /dev/null +++ b/ui/src/components/design-sandbox/ConversationPlayback.tsx @@ -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
+
+ + {started && !ended ? : null} + {onShowSavedThread ? : null} + {started ? ended ? "Conversation complete" : `${playing ? "" : "Paused · "}${CONVERSATION_PLAYBACK_STAGES[stage]?.label}` : "Watch a typical conversation · synthetic preview"} +
+ + + {followUp ? <> + + + : null} +
: undefined} /> + + ; +} diff --git a/ui/src/components/design-sandbox/ConversationPlaybackTurn.tsx b/ui/src/components/design-sandbox/ConversationPlaybackTurn.tsx new file mode 100644 index 00000000000..49a9fa49cf6 --- /dev/null +++ b/ui/src/components/design-sandbox/ConversationPlaybackTurn.tsx @@ -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) => ; + return + {!pending ? : null} + item !== pending)} excludeFinal emptyMessage={snapshot.emptyMessage} /> + {pending ? : null} + }> + {complete => + + } + ; +} diff --git a/ui/src/components/design-sandbox/DesignSandboxTaskThread.test.tsx b/ui/src/components/design-sandbox/DesignSandboxTaskThread.test.tsx new file mode 100644 index 00000000000..d12a1ac9965 --- /dev/null +++ b/ui/src/components/design-sandbox/DesignSandboxTaskThread.test.tsx @@ -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[0]; onStop?: () => void; + }) => pause ? : takeover ? takeover.content : <> + + {onStop ? : 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( + + Task title} agentName="Preview engineer" defaultConversation={defaultConversation}> +

Saved conversation

+
+
+
)); +} +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('[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(); +}); diff --git a/ui/src/components/design-sandbox/DesignSandboxTaskThread.tsx b/ui/src/components/design-sandbox/DesignSandboxTaskThread.tsx new file mode 100644 index 00000000000..10f985c1e5e --- /dev/null +++ b/ui/src/components/design-sandbox/DesignSandboxTaskThread.tsx @@ -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(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 choose("saved")} />; + + return
+
+ + choose(event.target.value)} className="w-auto min-w-0 max-w-full text-xs" aria-label="Thread preview state"> + + + {[...new Set(THREAD_PREVIEW_CASES.map(item => item.group))].map(group => + {THREAD_PREVIEW_CASES.filter(item => item.group === group).map(item => )} + )} + + {selected !== "saved" ? <> + + + Production renderers · synthetic data · preview actions stay local + : null} +
+ {selected === "saved" ? children : +
+ {selected === "all" ?
{header}
: null} + {cases.map(example => )} +
+
} +
; +} + +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>({}); + const [votes, setVotes] = useState>({}); + const [stopped, setStopped] = useState(false); + const [resumed, setResumed] = useState(false); + const [takeoverDismissed, setTakeoverDismissed] = useState(false); + const [workMode, setWorkMode] = useState("standard"); + const [messages, setMessages] = useState([]); + 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 ? { 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
+ {!replay ?

{example.label}

: null} + + {scenario.renderer === "native" ? : + + + {failed ? : null} + } + : null} /> + {showComposer ? + 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: , + 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" }, + ])} + /> + : null} +
; +} diff --git a/ui/src/components/design-sandbox/settled-preview-reply.ts b/ui/src/components/design-sandbox/settled-preview-reply.ts new file mode 100644 index 00000000000..1c5b06149a4 --- /dev/null +++ b/ui/src/components/design-sandbox/settled-preview-reply.ts @@ -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[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 }] }]])); +} diff --git a/ui/src/components/design-sandbox/task-thread-fixtures.ts b/ui/src/components/design-sandbox/task-thread-fixtures.ts new file mode 100644 index 00000000000..1c21e8c96e8 --- /dev/null +++ b/ui/src/components/design-sandbox/task-thread-fixtures.ts @@ -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, 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 => ({ + 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 = {}) => 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 => ({ + 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 = { + "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); +} diff --git a/ui/src/components/design-sandbox/word-reveal.test.ts b/ui/src/components/design-sandbox/word-reveal.test.ts new file mode 100644 index 00000000000..778a6ce2a74 --- /dev/null +++ b/ui/src/components/design-sandbox/word-reveal.test.ts @@ -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"); + }); +}); diff --git a/ui/src/components/design-sandbox/word-reveal.ts b/ui/src/components/design-sandbox/word-reveal.ts new file mode 100644 index 00000000000..59bca3591e6 --- /dev/null +++ b/ui/src/components/design-sandbox/word-reveal.ts @@ -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))))); +} diff --git a/ui/src/components/issue-properties/primitives.tsx b/ui/src/components/issue-properties/primitives.tsx index ce2a7666a7c..dcc1c8aef7f 100644 --- a/ui/src/components/issue-properties/primitives.tsx +++ b/ui/src/components/issue-properties/primitives.tsx @@ -21,6 +21,8 @@ export function PropertySection({
{title ? (
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, )} > diff --git a/ui/src/components/side-panel/use-scrollbar-while-scrolling.ts b/ui/src/components/side-panel/use-scrollbar-while-scrolling.ts index 013caf90be4..03db8dae243 100644 --- a/ui/src/components/side-panel/use-scrollbar-while-scrolling.ts +++ b/ui/src/components/side-panel/use-scrollbar-while-scrolling.ts @@ -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 { +export function useScrollbarWhileScrolling(): UIEventHandler { const idleTimerRef = useRef(null); - const activeElementRef = useRef(null); + const activeElementRef = useRef(null); - const handleScroll = useCallback>((event) => { + const handleScroll = useCallback>((event) => { const element = event.currentTarget; activeElementRef.current = element; element.dataset.scrollActive = "true"; diff --git a/ui/src/components/skill-studio/SkillsNavigationTabs.tsx b/ui/src/components/skill-studio/SkillsNavigationTabs.tsx new file mode 100644 index 00000000000..4f98cabcbd3 --- /dev/null +++ b/ui/src/components/skill-studio/SkillsNavigationTabs.tsx @@ -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 ; +} + +/** 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 ?
: fallback; +} diff --git a/ui/src/components/skill-studio/SkillsToolbar.tsx b/ui/src/components/skill-studio/SkillsToolbar.tsx new file mode 100644 index 00000000000..47839e34e45 --- /dev/null +++ b/ui/src/components/skill-studio/SkillsToolbar.tsx @@ -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 = { + 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 ( +
+
+

{viewTitle}

+

{viewDescription}

+
+ + + + + + + onSortChange(value as DiscoverySort)}> + {DISCOVERY_SORTS.map((option) => ( + + {DISCOVERY_SORT_LABELS[option]} + + ))} + + + + {availableSources.length > 1 ? ( + + + + + + + All sources + {availableSources.map((source) => ( + + {source.label} + + ))} + + + + ) : null} + + {folderControl} + {onCreateFolder ? ( + + ) : null} + {onToggleSelectMode ? ( + + ) : null} + + + + + + + + Create new skill + + + + Discover skills + + Import from GitHub + Manage sources + + + Import from path or URL + + + + Import skills from project + + + + {navigation ?
{navigation}
: null} +
+ ); +} diff --git a/ui/src/components/task-chat/TaskChatActivityPhase.tsx b/ui/src/components/task-chat/TaskChatActivityPhase.tsx index f2ebeff67e0..127699ff31e 100644 --- a/ui/src/components/task-chat/TaskChatActivityPhase.tsx +++ b/ui/src/components/task-chat/TaskChatActivityPhase.tsx @@ -83,6 +83,7 @@ export function TaskChatActivityPhase({
{item.interstitial.text} diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 3050a00b6e6..848db884a4f 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -219,6 +219,7 @@ function TaskChatBubbleContent({ : Math.max(0, mediaRefs.findIndex((ref) => ref.url === lightboxSrc)); return (
0 ? (
- + {active ? ( @@ -86,7 +88,7 @@ export function TaskChatLiveRunPill({ )} - {active ? {verb} : verb} + {active ? {verb} : verb} {suffix ? {suffix} : null} {toolSummary ? · {toolSummary} : null} diff --git a/ui/src/components/task-chat/TaskChatLiveTail.tsx b/ui/src/components/task-chat/TaskChatLiveTail.tsx index 28b73383c30..8c372c36009 100644 --- a/ui/src/components/task-chat/TaskChatLiveTail.tsx +++ b/ui/src/components/task-chat/TaskChatLiveTail.tsx @@ -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 ? ( -
{emptyMessage}
+ const placeholder = visibleTranscriptPlaceholder(emptyMessage); + return placeholder ? ( +
{placeholder}
) : 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"} > {item.text} diff --git a/ui/src/components/task-chat/TaskChatPlanPreviewCard.tsx b/ui/src/components/task-chat/TaskChatPlanPreviewCard.tsx index 99ddea242e4..e65b51e49bb 100644 --- a/ui/src/components/task-chat/TaskChatPlanPreviewCard.tsx +++ b/ui/src/components/task-chat/TaskChatPlanPreviewCard.tsx @@ -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)")} /> - + {live ? "Writing plan" : "Plan"} {headerDetail ? ( · {headerDetail} diff --git a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx index 9a1111a58a3..db708d8f918 100644 --- a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx @@ -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 - {label}{active ? "…" : ""} - {presentation.detail ? {presentation.detail} : null} + + {label}{active ? "…" : ""} + {presentation.detail ? {presentation.detail} : null} + {expandable ? ( - + {row.label} @@ -209,7 +212,7 @@ function RollingActivity({ function ActivityDetails({ item }: { item: Activity }) { if (item.kind === "thinking") - return {item.lines.join("\n")}; + return
{item.lines.join("\n")}
; if (item.kind === "usage") return ; if (item.kind === "protocol") return ; @@ -335,6 +338,7 @@ export function TaskChatRunnerActivityGroup({
{item.interstitial.text} diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index 30263f96cd4..1e48943080f 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -14,6 +14,7 @@ import { formatTaskChatTimestamp } from "./task-chat-adapter"; import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup"; import { TaskChatProtocolCard } from "./TaskChatProtocolCard"; import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard"; +import { TaskChatShimmerScope, useTaskChatShimmer } from "./TaskChatShimmer"; import { buildTurnTimelineRows, isTerminalRunStatus, @@ -87,16 +88,9 @@ function RunnerTurnStatus({ const failed = terminalStatusFailed(status); const label = terminal ? (failed ? "Stopped" : "Worked") : waitingLabel ?? "Working"; - const semanticLabel = terminal - ? elapsed - ? `${label} ${failed ? "after" : "for"} ${elapsed}` - : label - : waitingLabel - ? `${label}${elapsed ? ` · ${elapsed}` : ""}` - : `${label} for ${elapsed ?? "0s"}`; - const visibleLabel = continuedAfterSteering - ? `Continued after steering · ${semanticLabel}` - : semanticLabel; + const suffix = terminal + ? elapsed ? ` ${failed ? "after" : "for"} ${elapsed}` : "" + : waitingLabel ? elapsed ? ` · ${elapsed}` : "" : ` for ${elapsed ?? "0s"}`; return ( - {visibleLabel} + {continuedAfterSteering ? Continued after steering · : null} + {label}{suffix} ); } function RunnerCurrentActivityTail({ status, waitingLabel }: { status: string; waitingLabel?: string | null }) { + const shimmer = useTaskChatShimmer("working"); if (isTerminalRunStatus(status)) return null; return
- {waitingLabel ?? "Thinking"} + {waitingLabel ?? "Thinking"}
; } @@ -208,7 +204,7 @@ export function TaskChatRunnerTurn({ ); return ( -
) : null} {!final && currentActivityItems.length === 0 ? : null} -
+
); } diff --git a/ui/src/components/task-chat/TaskChatShimmer.test.ts b/ui/src/components/task-chat/TaskChatShimmer.test.ts new file mode 100644 index 00000000000..f888b6f6a3f --- /dev/null +++ b/ui/src/components/task-chat/TaskChatShimmer.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from "vitest"; +import { taskChatShimmerOwner, visibleTranscriptPlaceholder } from "./TaskChatShimmer"; +import { THREAD_PREVIEW_CASES } from "../design-sandbox/task-thread-fixtures"; +import { transcriptToTaskChatItems } from "./transcript-adapter"; +import type { TaskChatItem } from "./task-chat-model"; + +const itemsFor = (id: string) => transcriptToTaskChatItems(THREAD_PREVIEW_CASES.find(item => item.id === id)!.build().entries, { runId: "shimmer-test", running: true }); +it("hands off motion from startup to reasoning to the latest tool, then stops for final text", () => { + expect(taskChatShimmerOwner([])).toBe("working"); + for (const [fixture, kind] of [["thinking", "thinking"], ["working", "tool"], ["tool-call", "tool"]]) { + const items = itemsFor(fixture); + const owner = items.find(item => item.id === taskChatShimmerOwner(items)); + expect(owner?.kind).toBe(kind); + } + expect(taskChatShimmerOwner(itemsFor("streaming-final"))).toBeNull(); + expect(taskChatShimmerOwner(itemsFor("permission-pending"))).toBeNull(); + expect(taskChatShimmerOwner(itemsFor("tool-completed"))).toBe("working"); +}); +it("never reanimates an earlier outstanding tool after the provider moves on", () => { + const tools: TaskChatItem[] = [ + { id: "old", kind: "tool", name: "exec_command", status: "in_progress" }, + { id: "new", kind: "tool", name: "read_file", status: "in_progress" }, + ]; + expect(taskChatShimmerOwner(tools)).toBe("new"); + expect(taskChatShimmerOwner([...tools, { id: "reply", kind: "message", author: "agent", text: "Done", channel: "final", streaming: true }])).toBeNull(); +}); +it("omits preparation plumbing but preserves meaningful waiting, recovery and errors", () => { + for (const message of ["Preparing the turn…", "Preparing the transcript...", "Waiting for transcript...", "Loading transcript", "run phase: prepare_turn (930ms)"]) { + expect(visibleTranscriptPlaceholder(message)).toBeUndefined(); + } + for (const message of ["Waiting to start…", "Waiting for permission…", "Live runner activity is temporarily unavailable. Retrying…", "Workspace setup failed before the agent started."]) { + expect(visibleTranscriptPlaceholder(message)).toBe(message); + } +}); diff --git a/ui/src/components/task-chat/TaskChatShimmer.tsx b/ui/src/components/task-chat/TaskChatShimmer.tsx new file mode 100644 index 00000000000..ddff9d84562 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatShimmer.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext, type ReactNode } from "react"; +import type { TaskChatItem } from "./task-chat-model"; +import { protocolActivityIsRunning } from "./task-chat-activity-presentation"; + +const ShimmerOwner = createContext(undefined); + +/** One current surface owns motion. Do not revive an earlier tool merely + * because the provider has not sent its completion frame yet. */ +export function taskChatShimmerOwner(items: readonly TaskChatItem[]): string | null { + for (let index = items.length - 1; index >= 0; index--) { + const item = items[index]; + if (item.kind === "usage" || item.kind === "status") continue; + if (item.kind === "thinking") return item.streaming ? item.id : "working"; + if (item.kind === "tool") return item.status === "in_progress" || item.status === "pending" ? item.id : "working"; + if (item.kind === "message") return item.streaming ? null : "working"; + if (item.kind === "protocol") { + if (item.surface === "runtime_request" && item.status === "pending") return null; + return protocolActivityIsRunning(item) ? item.id : "working"; + } + } + return "working"; +} + +export function TaskChatShimmerScope({ items, children }: { items: readonly TaskChatItem[]; children: ReactNode }) { + return {children}; +} + +/** Standalone specimens retain their own state unless a turn elects an owner. */ +export function useTaskChatShimmer(id: string): boolean { + const owner = useContext(ShimmerOwner); + return owner === undefined || owner === id; +} + +/** Startup plumbing is already represented by Working, rather than appearing + * as a fake activity. Keep actionable queues, recovery and errors visible. */ +export function visibleTranscriptPlaceholder(message?: string): string | undefined { + if (!message) return undefined; + const text = message.trim(); + return /^(?:(?:preparing|loading|waiting for)\s+(?:the\s+)?(?:transcript|turn)(?:[.…]+)?|run phase:\s*(?:prepare_turn|prepare_transcript)\b.*)$/iu.test(text) ? undefined : message; +} diff --git a/ui/src/components/task-chat/TaskChatThinking.tsx b/ui/src/components/task-chat/TaskChatThinking.tsx index bc611355dcc..c4f674ebd3b 100644 --- a/ui/src/components/task-chat/TaskChatThinking.tsx +++ b/ui/src/components/task-chat/TaskChatThinking.tsx @@ -4,6 +4,7 @@ import { cn } from "@/lib/utils"; import { MarkdownBody } from "@/components/MarkdownBody"; import type { TaskChatThinkingItem } from "./task-chat-model"; import { flattenSelfTalk } from "./transcript-adapter"; +import { useTaskChatShimmer } from "./TaskChatShimmer"; /** Provider-authored reasoning summaries and deltas; never reconstructed hidden reasoning. */ export function TaskChatThinking({ @@ -21,6 +22,7 @@ export function TaskChatThinking({ active?: boolean; }) { const [open, setOpen] = useState(defaultOpen ?? false); + const shimmer = useTaskChatShimmer(item.id); const body = item.lines.join("\n").trim(); const preview = flattenSelfTalk(body); const baseLabel = item.channel === "detail" ? "Reasoning detail" : "Reasoning"; @@ -45,7 +47,7 @@ export function TaskChatThinking({ data-testid="task-chat-thinking-icon" /> -
+
{body}
@@ -58,7 +60,7 @@ export function TaskChatThinking({ - {label} + {label}
); } @@ -76,7 +78,7 @@ export function TaskChatThinking({
{active ? ( - {label} + {label} ) : null} {!open ? ( {open ? ( -
+
{body}
) : null} diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index fd2f66d4845..82e9b4fef50 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -364,6 +364,7 @@ export function TaskChatThreadView({ key={ item.kind === "message" ? (item.renderKey ?? item.id) : item.id } + data-conversation-typography="" data-thread-anchor={ item.kind === "message" ? (item.renderKey ?? item.id) : item.id } @@ -384,6 +385,7 @@ export function TaskChatThreadView({ key={ item.kind === "message" ? (item.renderKey ?? item.id) : item.id } + data-conversation-typography="" data-thread-anchor={ item.kind === "message" ? (item.renderKey ?? item.id) : item.id } @@ -440,7 +442,7 @@ export function TaskChatThreadView({
) : null} {history} - {tail ? streamlined ?
{tail}
: tail : null} + {tail ?
{tail}
: null}
); diff --git a/ui/src/components/task-chat/TaskChatToolCard.tsx b/ui/src/components/task-chat/TaskChatToolCard.tsx index 4c28dca3731..4204963561c 100644 --- a/ui/src/components/task-chat/TaskChatToolCard.tsx +++ b/ui/src/components/task-chat/TaskChatToolCard.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import type { TaskChatToolItem } from "./task-chat-model"; import { toolTaxonomy } from "./tool-taxonomy"; +import { useTaskChatShimmer } from "./TaskChatShimmer"; const STATUS_ICON = { pending: { Icon: Loader2, spin: false, tone: "text-muted-foreground" }, @@ -31,6 +32,7 @@ const STATUS_ICON = { */ export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) { const { Icon, spin, tone } = STATUS_ICON[item.status]; + const shimmer = useTaskChatShimmer(item.id); const RowIcon = toolTaxonomy(item.rawName ?? item.name).icon; const [showDetail, setShowDetail] = useTaskChatExpansion(item.id, false); const expandable = Boolean(item.target || item.detail || item.diff); @@ -55,7 +57,7 @@ export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) { data-testid="task-chat-tool-icon" /> - + {item.name} {item.target ? ( diff --git a/ui/src/components/task-chat/TaskChatTurnHandoff.tsx b/ui/src/components/task-chat/TaskChatTurnHandoff.tsx new file mode 100644 index 00000000000..39b634fe76b --- /dev/null +++ b/ui/src/components/task-chat/TaskChatTurnHandoff.tsx @@ -0,0 +1,48 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useReducedMotion } from "motion/react"; +import { parseCssTimeMs } from "./motion-tokens"; + +/** Keep the answer mounted while its preceding live activity fades, then + * releases its space. Historical mounts never replay the completion motion. */ +export function TaskChatTurnHandoff({ settled, activity, children }: { + settled: boolean; + activity: ReactNode; + children: (complete: boolean) => ReactNode; +}) { + const reducedMotion = useReducedMotion(); + const previousActivity = useRef(null); + const [finished, setFinished] = useState(false); + useLayoutEffect(() => { + if (!settled) { + previousActivity.current = activity; + setFinished(false); + } + }, [settled, activity]); + const transitioning = settled && Boolean(previousActivity.current) && reducedMotion === false && !finished; + const complete = settled && !transitioning; + useEffect(() => { + if (!transitioning) return; + // Transition events can be skipped in a background tab or when motion + // tokens are set to zero. Always release the retained activity eventually. + const styles = getComputedStyle(document.documentElement); + const duration = ["--motion-conversation-handoff-fade", "--motion-conversation-handoff-fold", "--motion-duration-fast"] + .reduce((sum, token) => sum + parseCssTimeMs(styles.getPropertyValue(token)), 0); + const timer = window.setTimeout(() => setFinished(true), duration); + return () => window.clearTimeout(timer); + }, [transitioning]); + return
+
{ + if (event.target === event.currentTarget && event.propertyName === "grid-template-rows" && settled) setFinished(true); + }} + > +
{settled ? transitioning ? previousActivity.current : null : activity}
+
+
{children(complete)}
+
; +} diff --git a/ui/src/components/task-chat/motion-tokens.ts b/ui/src/components/task-chat/motion-tokens.ts index 4a7e58dc5ae..08086aca402 100644 --- a/ui/src/components/task-chat/motion-tokens.ts +++ b/ui/src/components/task-chat/motion-tokens.ts @@ -30,6 +30,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [ { name: "--motion-ease-scroll-pill-in", group: "Easing", kind: "easing" }, { name: "--motion-ease-scroll-pill-out", group: "Easing", kind: "easing" }, { name: "--motion-ease-in", group: "Easing", kind: "easing" }, + { name: "--motion-ease-pane-width-reset", group: "Easing", kind: "easing" }, // Duration primitives { name: "--motion-duration-instant", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 }, @@ -59,6 +60,13 @@ export const MOTION_TOKENS: MotionTokenDef[] = [ { name: "--motion-count-tween", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-streaming-cursor-blink", group: "States", kind: "time", min: 0, max: 3000, step: 20 }, { name: "--motion-turn-fold", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, + { name: "--motion-conversation-handoff-fade", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, + { name: "--motion-conversation-handoff-fold", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, + { name: "--motion-conversation-word-fade", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, + { name: "--motion-conversation-tool-shimmer", group: "States", kind: "time", min: 0, max: 5000, step: 50 }, + { name: "--motion-sandbox-playback-step", group: "Preview", kind: "time", min: 0, max: 10000, step: 100 }, + { name: "--motion-sandbox-stream-tick", group: "Preview", kind: "time", min: 0, max: 1000, step: 10 }, + { name: "--motion-sandbox-word-tick", group: "Preview", kind: "time", min: 0, max: 1000, step: 10 }, { name: "--motion-turn-meta", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-line-scroll", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-interstitial-dwell", group: "States", kind: "time", min: 0, max: 10000, step: 100 }, @@ -68,6 +76,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [ { name: "--motion-mobile-nav-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-scrollbar-idle-delay", group: "States", kind: "time", min: 0, max: 2000, step: 10 }, { name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, + { name: "--motion-pane-width-reset", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-side-panel-tab", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, ]; diff --git a/ui/src/components/ui/avatar-initials.tsx b/ui/src/components/ui/avatar-initials.tsx new file mode 100644 index 00000000000..e708bc747df --- /dev/null +++ b/ui/src/components/ui/avatar-initials.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext, type ReactNode } from "react"; + +const SingleInitialContext = createContext(false); + +/** Proposed fallback policy; shared avatars keep their existing default until + * a surface explicitly adopts the single-initial design. */ +export function AvatarInitialProvider({ children, singleInitial = true }: { children: ReactNode; singleInitial?: boolean }) { + return {children}; +} + +export function useAvatarInitials(initials: string) { + const singleInitial = useContext(SingleInitialContext); + return singleInitial ? Array.from(initials.trim().toLocaleUpperCase())[0] ?? "" : initials; +} diff --git a/ui/src/components/ui/avatar.tsx b/ui/src/components/ui/avatar.tsx index d99ec418ae5..5634d069287 100644 --- a/ui/src/components/ui/avatar.tsx +++ b/ui/src/components/ui/avatar.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { Avatar as AvatarPrimitive } from "radix-ui" import { cn } from "@/lib/utils" +import { useAvatarInitials } from "./avatar-initials" function Avatar({ className, @@ -42,8 +43,10 @@ function AvatarImage({ function AvatarFallback({ className, + children, ...props }: React.ComponentProps) { + const initials = useAvatarInitials(typeof children === "string" ? children : "") return ( + > + {typeof children === "string" ? initials : children} + ) } diff --git a/ui/src/components/ui/card.tsx b/ui/src/components/ui/card.tsx index c4e82f85183..6b7ec9cfad7 100644 --- a/ui/src/components/ui/card.tsx +++ b/ui/src/components/ui/card.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/utils" function Card({ className, interactive = false, + elevation, ...props }: React.ComponentProps<"div"> & { /** @@ -13,10 +14,13 @@ function Card({ * it — one Card, two modes. */ interactive?: boolean + /** Surface level in the proposed theme; omitted keeps the default card surface. */ + elevation?: "low" | "middle" | "high" }) { return (
) { + const appearance = useFlyoutAppearance() return ( ) { + const appearance = useFlyoutAppearance() return ( - + {appearance === "refreshed" ? : } {children} @@ -226,9 +230,11 @@ function DropdownMenuSubContent({ className, ...props }: React.ComponentProps) { + const appearance = useFlyoutAppearance() return ( ("default"); + +/** Opt existing dropdown and popover primitives into the proposed shared style. + * React context follows portals, so menus retain their existing behavior. */ +export function FlyoutAppearanceProvider({ children, appearance = "refreshed" }: { children: ReactNode; appearance?: FlyoutAppearance }) { + return {children}; +} + +export function useFlyoutAppearance() { + return useContext(FlyoutAppearanceContext); +} diff --git a/ui/src/components/ui/flyout.tsx b/ui/src/components/ui/flyout.tsx new file mode 100644 index 00000000000..2c94b30aa7c --- /dev/null +++ b/ui/src/components/ui/flyout.tsx @@ -0,0 +1,26 @@ +import { forwardRef, type ComponentProps, type CSSProperties } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; +import { FlyoutAppearanceProvider } from "./flyout-appearance"; +import { cn } from "@/lib/utils"; + +export { FlyoutAppearanceProvider, useFlyoutAppearance, type FlyoutAppearance } from "./flyout-appearance"; + +/** Proposed popover composition. Dropdowns reuse the same header, options and + * appearance provider while retaining their menu-specific keyboard behavior. */ +export function Flyout(props: ComponentProps) { + return ; +} + +export const FlyoutTrigger = PopoverTrigger; + +export function FlyoutContent({ className, style, shadow, ...props }: ComponentProps & { shadow?: CSSProperties["boxShadow"] }) { + return ; +} + +export const FlyoutHeader = forwardRef>(function FlyoutHeader(props, ref) { + return
; +}); + +export const FlyoutOptions = forwardRef>(function FlyoutOptions(props, ref) { + return
; +}); diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx index c250a7d9959..96e374bc1da 100644 --- a/ui/src/components/ui/popover.tsx +++ b/ui/src/components/ui/popover.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { Popover as PopoverPrimitive } from "radix-ui" import { cn } from "@/lib/utils" +import { useFlyoutAppearance } from "./flyout-appearance" function Popover({ ...props @@ -22,9 +23,11 @@ function PopoverContent({ disablePortal = false, ...props }: React.ComponentProps & { disablePortal?: boolean }) { + const appearance = useFlyoutAppearance() const content = ( ).IS_REACT_ACT_ENVIRONMENT = true; +let root: Root; +let host: HTMLDivElement; +function Subject() { return <>{String(useDesignSandbox())}:{useFlyoutAppearance()}{useAvatarInitials("BO")}; } +function render() { act(() => root.render()); } +beforeEach(() => { + localStorage.clear(); + vi.stubEnv("DEV", true); + vi.stubEnv("VITE_PAPERCLIP_DESIGN_SANDBOX", "true"); + host = document.createElement("div"); document.body.append(host); root = createRoot(host); +}); +afterEach(() => { act(() => root.unmount()); host.remove(); document.body.classList.remove("design-proposal-theme"); localStorage.clear(); vi.unstubAllEnvs(); }); + +it("requires a dev instance opt-in; browser storage cannot activate a production build", () => { + vi.stubEnv("DEV", false); render(); + expect(host.querySelector("output")?.textContent).toBe("false:default"); + expect(host.querySelector("button")).toBeNull(); + expect(document.body.classList.contains("design-proposal-theme")).toBe(false); +}); +it("does not affect ordinary development instances", () => { + vi.stubEnv("VITE_PAPERCLIP_DESIGN_SANDBOX", "false"); render(); + expect(host.querySelector("output")?.textContent).toBe("false:default"); + expect(document.body.classList.contains("design-proposal-theme")).toBe(false); +}); +it("toggles the exact proposal theme and portaled-menu appearance together and restores defaults", () => { + render(); + expect(host.querySelector("output")?.textContent).toBe("true:refreshed"); + expect(host.querySelector('[data-testid="avatar-initials"]')?.textContent).toBe("B"); + expect(document.body.classList.contains("design-proposal-theme")).toBe(true); + act(() => host.querySelector("button")!.click()); + expect(host.querySelector("output")?.textContent).toBe("false:default"); + expect(host.querySelector('[data-testid="avatar-initials"]')?.textContent).toBe("BO"); + expect(document.body.classList.contains("design-proposal-theme")).toBe(false); + expect(localStorage.getItem("paperclip.design-proposals.sandbox")).toBe("off"); +}); +it("honors the saved off preference and cleans up the marker when unmounted", () => { + localStorage.setItem("paperclip.design-proposals.sandbox", "off"); render(); + expect(host.querySelector("output")?.textContent).toBe("false:default"); + act(() => host.querySelector("button")!.click()); + expect(document.body.classList.contains("design-proposal-theme")).toBe(true); + act(() => root.render(null)); + expect(document.body.classList.contains("design-proposal-theme")).toBe(false); +}); diff --git a/ui/src/context/DesignSandboxContext.tsx b/ui/src/context/DesignSandboxContext.tsx new file mode 100644 index 00000000000..0da9292f8c1 --- /dev/null +++ b/ui/src/context/DesignSandboxContext.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext, useLayoutEffect, useState, type ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { FlyoutAppearanceProvider } from "@/components/ui/flyout"; +import { AvatarInitialProvider } from "@/components/ui/avatar-initials"; + +const storageKey = "paperclip.design-proposals.sandbox"; +const DesignSandboxContext = createContext(false); + +export function useDesignSandbox() { return useContext(DesignSandboxContext); } + +/** Local integration experiment. Neither production builds nor ordinary dev + * instances enable this through browser storage alone. */ +export function DesignSandboxProvider({ children }: { children: ReactNode }) { + const available = import.meta.env.DEV && import.meta.env.VITE_PAPERCLIP_DESIGN_SANDBOX === "true"; + const [selected, setSelected] = useState(() => { + try { return localStorage.getItem(storageKey) !== "off"; } catch { return true; } + }); + const enabled = available && selected; + useLayoutEffect(() => { + if (!enabled) return; + // Use the exact Storybook proposal marker, including its document-wide + // token propagation to portals. Broad propagation is intentional here. + const hadMarker = document.body.classList.contains("design-proposal-theme"); + document.body.classList.add("design-proposal-theme"); + return () => { if (!hadMarker) document.body.classList.remove("design-proposal-theme"); }; + }, [enabled]); + + return + + {children} + {available ?
+ +
: null} +
+
; +} diff --git a/ui/src/hooks/useHorizontalScrollEdges.ts b/ui/src/hooks/useHorizontalScrollEdges.ts new file mode 100644 index 00000000000..258423d323d --- /dev/null +++ b/ui/src/hooks/useHorizontalScrollEdges.ts @@ -0,0 +1,29 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +/** Track clipping as the viewport, content or scroll position changes. */ +export function useHorizontalScrollEdges() { + const scrollRef = useRef(null); + const contentRef = useRef(null); + const [edges, setEdges] = useState({ left: false, right: false }); + + useLayoutEffect(() => { + const scroller = scrollRef.current; + if (!scroller) return; + const measure = () => { + const left = scroller.scrollLeft > 1; + const right = scroller.scrollWidth - scroller.clientWidth - scroller.scrollLeft > 1; + setEdges(current => current.left === left && current.right === right ? current : { left, right }); + }; + measure(); + scroller.addEventListener("scroll", measure, { passive: true }); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure); + observer?.observe(scroller); + if (contentRef.current) observer?.observe(contentRef.current); + return () => { + scroller.removeEventListener("scroll", measure); + observer?.disconnect(); + }; + }, []); + + return { scrollRef, contentRef, edges }; +} diff --git a/ui/src/hooks/usePaneWidthReset.ts b/ui/src/hooks/usePaneWidthReset.ts new file mode 100644 index 00000000000..f272fe50ca8 --- /dev/null +++ b/ui/src/hooks/usePaneWidthReset.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useRef, useState, type TransitionEvent } from "react"; + +/** Enables width transitions only for an explicit reset, never for dragging. */ +export function usePaneWidthReset() { + const [resetting, setResetting] = useState(false); + const timer = useRef(null); + const cancelReset = useCallback(() => { + if (timer.current !== null) window.clearTimeout(timer.current); + timer.current = null; + setResetting(false); + }, []); + const startReset = useCallback(() => { + cancelReset(); + const value = getComputedStyle(document.documentElement).getPropertyValue("--motion-pane-width-reset").trim(); + const duration = Number.parseFloat(value) * (value.endsWith("ms") ? 1 : 1000); + if (!Number.isFinite(duration) || duration <= 0) return; + setResetting(true); + // Covers a no-op reset or a transition that is canceled without an end event. + timer.current = window.setTimeout(cancelReset, duration * 2); + }, [cancelReset]); + const onResetTransitionEnd = useCallback((event: TransitionEvent) => { + if (event.target === event.currentTarget && (event.propertyName === "width" || event.propertyName === "flex-basis")) { + cancelReset(); + } + }, [cancelReset]); + useEffect(() => () => { + if (timer.current !== null) window.clearTimeout(timer.current); + }, []); + return { resetting, startReset, cancelReset, onResetTransitionEnd }; +} diff --git a/ui/src/hooks/useTaskListCollection.test.tsx b/ui/src/hooks/useTaskListCollection.test.tsx new file mode 100644 index 00000000000..6075cd53520 --- /dev/null +++ b/ui/src/hooks/useTaskListCollection.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createIssue } from "../../storybook/fixtures/paperclipData"; +import type { TaskListItem } from "@/components/TaskList"; +import { taskListViewStorageKey } from "@/lib/task-list-views"; +import { useTaskListCollection } from "./useTaskListCollection"; + +(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; +const items: TaskListItem[] = [ + { issue: createIssue({ id: "parent", companyId: "company", parentId: null, title: "Connector reliability", createdByAgentId: null, createdByUserId: "same", assigneeUserId: null }), createdBy: "Scott", assignee: "CodexCoder" }, + { issue: createIssue({ id: "child", companyId: "company", parentId: "parent", title: "Review connector states", status: "in_review", createdByAgentId: "same", createdByUserId: null }), createdBy: "CodexCoder", unread: true }, + { issue: createIssue({ id: "grandchild", companyId: "company", parentId: "child", title: "Recover expired credentials", status: "todo", createdByAgentId: "same", createdByUserId: null }), createdBy: "CodexCoder" }, + { issue: createIssue({ id: "foreign", companyId: "other-company", parentId: null, createdByUserId: "foreign-user", createdByAgentId: null }), createdBy: "Another organization" }, +]; +const key = taskListViewStorageKey("collection-hook-test", "same"); +type Options = Parameters[0]; +let collection: ReturnType; +let root: Root; +let host: HTMLDivElement; +function Harness(props: Options) { collection = useTaskListCollection(props); return null; } +function render(overrides: Partial = {}) { + act(() => root.render()); +} +beforeEach(() => { + localStorage.clear(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + render(); +}); +afterEach(() => { act(() => root.unmount()); host.remove(); localStorage.clear(); }); + +describe("shared task-list collection behavior", () => { + it("distinguishes human and agent creators with the same raw ID and excludes other companies", () => { + expect(collection.creators).toEqual([ + { id: "user:same", label: "Scott", kind: "user" }, + { id: "agent:same", label: "CodexCoder", kind: "agent" }, + ]); + act(() => collection.updateFilters({ creators: ["user:same"] })); + expect(collection.matchingItems.map(item => item.issue.id)).toEqual(["parent"]); + act(() => collection.updateFilters({ creators: ["agent:same"] })); + expect(collection.matchingItems.map(item => item.issue.id)).toEqual(["child", "grandchild"]); + expect(collection.visibleItems.map(item => item.issue.id)).toEqual(["parent", "child", "grandchild"]); + act(() => collection.updateFilters({ creators: ["user:same", "agent:same"] })); + expect(collection.matchingItems).toHaveLength(3); + }); + + it("reveals a searched descendant after its ancestors were collapsed", () => { + act(() => collection.toggleExpanded()); + expect(collection.collapsedIds.has("parent")).toBe(true); + act(() => collection.setQuery("expired credentials")); + expect(collection.matchingItems.map(item => item.issue.id)).toEqual(["grandchild"]); + expect(collection.visibleItems.map(item => item.issue.id)).toEqual(["parent", "child", "grandchild"]); + expect(collection.collapsedIds.size).toBe(0); + }); + + it("expands matching families when filters or views change, and distinguishes matches from contextual parents", () => { + act(() => collection.toggleExpanded()); + act(() => collection.updateFilters({ statuses: ["in_review"] })); + expect(collection.collapsedIds.size).toBe(0); + expect(collection.matchingItems.map(item => item.issue.id)).toEqual(["child"]); + expect(collection.visibleItems.map(item => item.issue.id)).toEqual(["parent", "child"]); + act(() => { collection.clear(); }); + act(() => collection.toggleExpanded()); + act(() => collection.setView("unread")); + expect(collection.collapsedIds.size).toBe(0); + expect(collection.matchingItems.map(item => item.issue.id)).toEqual(["child"]); + }); + + it("searches metadata as well as titles, and ignores whitespace-only queries", () => { + act(() => collection.setQuery("Scott")); + expect(collection.matchingItems.map(item => item.issue.id)).toEqual(["parent"]); + act(() => collection.setQuery("CodexCoder")); + expect(collection.matchingItems).toHaveLength(3); + act(() => collection.setQuery(" ")); + expect(collection.narrowed).toBe(false); + expect(collection.matchingItems).toHaveLength(3); + }); + + it("keeps view preferences isolated and clears the active collection without losing layout", () => { + act(() => collection.setView("unread")); + act(() => collection.setLayout("board")); + render({ viewStorageKey: taskListViewStorageKey("another-trial", "same") }); + expect(collection.view).toBe("all"); + render(); + expect(collection.view).toBe("unread"); + act(() => collection.clear()); + expect(collection.view).toBe("all"); + expect(collection.query).toBe(""); + expect(collection.narrowed).toBe(false); + expect(collection.layout).toBe("board"); + expect(localStorage.getItem(key)).toBe("all"); + }); + + it("never includes foreign tasks or ancestor context when the company changes", () => { + act(() => collection.toggleExpanded()); + render({ companyId: "other-company" }); + expect(collection.companyItems.map(item => item.issue.id)).toEqual(["foreign"]); + expect(collection.visibleItems.map(item => item.issue.id)).toEqual(["foreign"]); + expect(collection.creators.map(creator => creator.id)).toEqual(["user:foreign-user"]); + expect(collection.collapsedIds.size).toBe(0); + }); +}); diff --git a/ui/src/hooks/useTaskListCollection.ts b/ui/src/hooks/useTaskListCollection.ts new file mode 100644 index 00000000000..36ba701739c --- /dev/null +++ b/ui/src/hooks/useTaskListCollection.ts @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { includeTaskListAncestors, type TaskListItem } from "@/components/TaskList"; +import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState, type IssueFilterState } from "@/lib/issue-filters"; +import { loadTaskListView, matchesTaskListView, saveTaskListView, type TaskListView } from "@/lib/task-list-views"; + +type CreatorOption = { id: string; label: string; kind: "agent" | "user" }; +const expanded: ReadonlySet = new Set(); + +/** Shared collection behavior for the proposed task UI. Supply accessible data; + * this hook scopes presentation, and does not replace server authorization. */ +export function useTaskListCollection({ items, companyId, userId, viewStorageKey, initialQuery = "" }: { + items: readonly TaskListItem[]; + companyId: string | null; + userId: string | null; + viewStorageKey: string; + initialQuery?: string; +}) { + const [query, setQueryState] = useState(initialQuery); + const [preference, setPreference] = useState(() => ({ key: viewStorageKey, view: loadTaskListView(viewStorageKey) })); + const view = preference.key === viewStorageKey ? preference.view : loadTaskListView(viewStorageKey); + const [layout, setLayout] = useState<"list" | "board">("list"); + const [filters, setFilters] = useState(defaultIssueFilterState); + const [collapse, setCollapse] = useState({ companyId, ids: expanded }); + const collapsedIds = collapse.companyId === companyId ? collapse.ids : expanded; + const setCollapsedIds = (ids: ReadonlySet) => setCollapse({ companyId, ids }); + const resetExpansion = () => setCollapsedIds(expanded); + + const companyItems = items.filter(item => item.issue.companyId === companyId); + const creatorsById = new Map(); + for (const item of companyItems) { + const { createdByAgentId, createdByUserId } = item.issue; + const rawId = createdByAgentId ?? createdByUserId; + if (!rawId) continue; + const kind = createdByAgentId ? "agent" : "user"; + const id = `${kind}:${rawId}`; + creatorsById.set(id, { id, kind, label: item.createdBy || rawId }); + } + const filteredIds = new Set(applyIssueFilters(companyItems.map(item => item.issue), filters, userId).map(issue => issue.id)); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const matchingItems = companyItems.filter(item => filteredIds.has(item.issue.id) + && matchesTaskListView(view, item.issue, Boolean(item.unread), userId) + && [item.issue.title, item.issue.identifier, item.createdBy, item.assignee, item.issue.project?.name, + ...(item.issue.labels ?? []).map(label => label.name)].some(value => value?.toLocaleLowerCase().includes(normalizedQuery))); + const visibleItems = includeTaskListAncestors(companyItems, matchingItems); + const parentIds = new Set(companyItems.flatMap(item => item.issue.parentId ? [item.issue.parentId] : [])); + const allExpanded = ![...parentIds].some(id => collapsedIds.has(id)); + const activeFilterCount = countActiveIssueFilters(filters); + + const setQuery = (next: string) => { setQueryState(next); resetExpansion(); }; + const setView = (next: TaskListView) => { + setPreference({ key: viewStorageKey, view: next }); + saveTaskListView(viewStorageKey, next); + resetExpansion(); + }; + const updateFilters = (patch: Partial) => { + setFilters(current => ({ ...current, ...patch })); + resetExpansion(); + }; + const clear = () => { setQuery(""); setFilters(defaultIssueFilterState); setView("all"); }; + + return { + query, setQuery, view, setView, layout, setLayout, filters, updateFilters, clear, + companyItems, matchingItems, visibleItems, creators: [...creatorsById.values()], + collapsedIds, setCollapsedIds, allExpanded, + toggleExpanded: () => setCollapsedIds(allExpanded ? parentIds : expanded), + activeFilterCount, narrowed: Boolean(normalizedQuery || activeFilterCount || view !== "all"), + }; +} diff --git a/ui/src/index.css b/ui/src/index.css index b5b9c816f79..7b3abdbfedf 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -123,6 +123,14 @@ font-weight: 100 900; } +@font-face { + font-family: "Paper Mono"; + src: url("/fonts/PaperMono-Variable.woff2") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 100 800; +} + @custom-variant dark (&:is(.dark *)); /* Shared file tree: preserve desktop indentation and leave room for labels on @@ -147,7 +155,6 @@ @theme inline { --font-sans: "InterVariable", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -159,6 +166,7 @@ --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); + --color-muted-highlight: var(--muted-highlight); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); @@ -195,8 +203,15 @@ --radius-4xl: calc(var(--radius) * 2.6); } +/* Keep this font runtime-resolved: the proposal/sandbox theme substitutes + Paper Mono for every shared font-mono consumer, including portaled content. */ +@theme { + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + :root { color-scheme: light; + --font-paper-mono: "Paper Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --file-tree-indent-base: calc(var(--spacing) * 2); --file-tree-indent-step: calc(var(--spacing) * 6); --file-tree-indent-max: 40%; @@ -211,6 +226,7 @@ --composer-effort-halo: 0 0 0 calc(var(--spacing) * 1.5) color-mix(in oklab, var(--tc-mode-ask) 14%, transparent); --radius: 0.5rem; --background: oklch(1 0 0); + --background-alt: var(--muted); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); @@ -221,6 +237,7 @@ --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); + --muted-highlight: var(--muted); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); @@ -229,6 +246,10 @@ --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); + --focus-ring-stroke-width: 1px; + --focus-ring-halo-width: 3px; + --focus-ring-halo: color-mix(in srgb, var(--ring) 20%, transparent); + --focus-ring-shadow: 0 0 0 var(--focus-ring-halo-width) var(--focus-ring-halo); --scrollbar-thumb: oklch(0.7 0 0); --scrollbar-thumb-hover: oklch(0.6 0 0); --scrollbar-size: 8px; @@ -239,6 +260,7 @@ --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); --sidebar: oklch(0.985 0 0); + --primary-sidebar-background: color-mix(in srgb, var(--border) 50%, var(--background)); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); @@ -417,7 +439,16 @@ --motion-question-page-enter: var(--motion-duration-instant); --motion-count-tween: var(--motion-duration-slow); --motion-streaming-cursor-blink: 1.1s; + /* Synthetic thread playback cadence; no provider or runtime is involved. */ + --motion-sandbox-stream-tick: 40ms; + --motion-sandbox-word-tick: 60ms; + --motion-conversation-word-fade: 320ms; + --conversation-word-start-opacity: 0.08; + --motion-conversation-tool-shimmer: 2.5s; + --motion-sandbox-playback-step: 4s; --motion-turn-fold: 380ms; /* finished-turn activity folding into its summary line */ + --motion-conversation-handoff-fade: 180ms; + --motion-conversation-handoff-fold: 480ms; --motion-turn-meta: var(--motion-duration-fast); /* finished-turn hover metadata reveal */ --motion-line-scroll: 240ms; /* completed interstitial line sliding out of the 1lh live-line viewport */ --motion-interstitial-dwell: 4000ms; /* interstitial update hold: minimum before the next swaps in, and how long a finished update sits before sliding away */ @@ -427,6 +458,8 @@ --motion-ease-scroll-pill-in: cubic-bezier(0.23, 1, 0.32, 1); --motion-ease-scroll-pill-out: cubic-bezier(0.7, 0, 0.84, 0); --motion-pane-glide: 320ms; /* properties-pane maximize/restore glide */ + --motion-pane-width-reset: 200ms; + --motion-ease-pane-width-reset: ease-in-out; --motion-side-panel-tab: var(--motion-duration-fast); /* Portable side-panel geometry and hierarchy. These stay centralized so @@ -526,6 +559,8 @@ --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); + --muted-highlight: #333333; /* Paper: --dark-color-muted-highlight. */ + --background-alt: oklch(0.145 0 0); /* Paper: --dark-color-background-alt. */ --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); @@ -542,6 +577,7 @@ --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); --sidebar: oklch(0.205 0 0); + --primary-sidebar-background: var(--muted); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); @@ -1072,6 +1108,7 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size=" --motion-scroll-pill-enter: 0ms; --motion-scroll-pill-exit: 0ms; --motion-pane-glide: 0ms; + --motion-pane-width-reset: 0ms; } } @@ -1213,6 +1250,9 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size=" /* Properties-pane maximize/restore glide (left animates between docked and sidebar-flush while the pane is position:fixed). */ .tc-pane-glide { transition: left var(--motion-pane-glide) var(--motion-ease-standard); } +.sidebar-shell[data-width-resetting="true"], +.sidebar-shell[data-width-resetting="true"] > .sidebar-shell-panel { transition: width var(--motion-pane-width-reset) var(--motion-ease-pane-width-reset); } +.design-page-aside[data-width-resetting="true"] { transition: flex-basis var(--motion-pane-width-reset) var(--motion-ease-pane-width-reset); } .side-panel-tab-motion { transition-property: color, background-color, border-color, opacity, box-shadow, transform; transition-duration: var(--motion-side-panel-tab); @@ -1226,12 +1266,9 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size=" /* Sidebar actions cover the trailing live label without moving row content, fading into the row surface like the task-detail tab close control. */ .primary-sidebar-surface { - --sidebar-action-rest-surface: color-mix(in srgb, var(--border) 50%, var(--background)); + --sidebar-action-rest-surface: var(--primary-sidebar-background); background-color: var(--sidebar-action-rest-surface); } -.dark .primary-sidebar-surface { - --sidebar-action-rest-surface: var(--muted); -} .sidebar-action-row { --sidebar-action-surface: var(--sidebar-action-rest-surface, var(--background)); } @@ -1568,7 +1605,6 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size=" /* Shimmer text effect for active "Working" state — Cursor-style sweep */ @keyframes shimmer-text-slide { 0% { background-position: 100% center; } - 60% { background-position: 0% center; } 100% { background-position: 0% center; } } @@ -1993,14 +2029,14 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { .paperclip-mdxeditor-content h1 { margin: 1.4em 0 0.9em; font-size: 1.75em; - font-weight: 700; + font-weight: var(--font-weight-bold); line-height: 1.2; } .paperclip-mdxeditor-content h2 { margin: 1.3em 0 0.85em; font-size: 1.35em; - font-weight: 700; + font-weight: var(--font-weight-bold); line-height: 1.3; } @@ -2199,7 +2235,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { } .paperclip-markdown-codeblock-action-label { - font-weight: 500; + font-weight: var(--font-weight-medium); } /* Remove backtick pseudo-elements from inline code (prose default adds them) */ @@ -2213,7 +2249,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { background-color: color-mix(in oklab, var(--accent) 60%, transparent); padding: 0.15em 0.35em; border-radius: 3px; - font-weight: 500; + font-weight: var(--font-weight-medium); } .dark .prose :not(pre) > code { @@ -3508,3 +3544,1153 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { @media (max-height: 420px) { .agent-setup-popover { overflow-y: auto; } } + +/* Task-row design specimen. Opt-in until visual review; production defaults stay + unchanged. Values live here so both densities and every story share one kit. */ +:root, .dark { + --task-row-height-comfortable: calc(var(--spacing) * 11); + --task-row-height-compact: calc(var(--spacing) * 8); + --task-row-padding-inline: calc(var(--spacing) * 4); + --task-row-gap: calc(var(--spacing) * 2); + --task-row-indent-step: calc(var(--spacing) * 6); + --task-row-control-size: calc(var(--spacing) * 4); + --task-row-caret-size: calc(var(--spacing) * 3.5); + --task-row-title-size: var(--text-sm); + --task-row-title-leading: calc(var(--spacing) * 4); + --task-row-font-mono: var(--font-paper-mono); + --task-row-title-weight-read: var(--font-weight-normal); + --task-row-title-weight-unread: var(--font-weight-semibold); + --task-row-title-color-read: var(--muted-foreground); + --task-row-title-color-active: var(--foreground); + --task-row-surface: transparent; + --task-row-surface-hover: var(--accent); + --task-row-surface-selected: color-mix(in oklab, var(--accent) 50%, transparent); + --task-row-surface-current: color-mix(in oklab, var(--primary) 5%, transparent); + --task-row-rule-color: var(--border); + --task-row-rule-width: 1px; + --task-row-radius: 0px; + --task-row-focus-width: 2px; + --task-row-focus-color: var(--ring); + --task-row-guide-offset: calc(var(--task-row-indent-step) + var(--task-row-control-size) / 2); + --task-row-identifier-width: calc(var(--spacing) * 20); + --task-row-time-width: calc(var(--spacing) * 24); + --task-row-title-marquee-delay: var(--motion-duration-fast); + --task-row-title-marquee-step-duration: 25ms; + --task-row-title-marquee-ease: linear; + --cell-marquee-fade-width: calc(var(--spacing) * 3); + --cell-marquee-mask-solid: #000; /* Alpha mask only; reveals the actual surface beneath the cell. */ +} + +.task-row-group { + background: var(--task-list-surface); + border-block: var(--task-row-rule-width) solid var(--task-row-rule-color); +} +.task-row-group + .task-row-group { border-top: 0; } + +.task-row-refreshed { + --task-row-height: var(--task-row-height-comfortable); + --task-row-guide-end-gap: calc((var(--task-row-height) - var(--task-row-control-size)) / 2); + height: var(--task-row-height); + min-height: var(--task-row-height); + align-items: center; + gap: var(--task-row-gap); + padding: 0 var(--task-row-padding-inline); + border: 0; + border-radius: var(--task-row-radius); + background: var(--task-row-surface); + font-family: var(--font-sans); + font-size: var(--task-row-title-size); + line-height: var(--task-row-title-leading); +} +.task-row-refreshed[data-density="compact"] { + --task-row-height: var(--task-row-height-compact); +} +.task-row-refreshed * { + font-size: var(--task-row-title-size); + line-height: var(--task-row-title-leading); +} +.task-row-status-tooltip { + font-size: var(--task-row-title-size); + line-height: var(--task-row-title-leading); +} +.task-row-refreshed:hover { background: var(--task-row-surface-hover); } +.task-row-refreshed[data-selected="true"] { background: var(--task-row-surface-selected); } +.task-row-refreshed[data-current-step="true"] { background: var(--task-row-surface-current); } +.task-row-refreshed .task-row-link { border-radius: var(--task-row-radius); } +.task-row-refreshed .task-row-link:focus-visible { + outline: var(--task-row-focus-width) solid var(--task-row-focus-color); + outline-offset: calc(var(--task-row-focus-width) * -1); + box-shadow: none; +} +.task-row-refreshed [data-slot="task-row-leading"] { + align-items: center; + gap: var(--task-row-gap); + padding-top: 0; +} +.task-row-status-link { + position: relative; + z-index: 10; + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: var(--task-row-control-size); + height: var(--task-row-control-size); +} +.task-row-status-link svg { pointer-events: none; } +.task-row-status-link:focus-visible { + outline: var(--task-row-focus-width) solid var(--task-row-focus-color); + outline-offset: var(--task-row-rule-width); + border-radius: var(--radius-sm); +} +.task-row-refreshed [data-slot="task-row-leading-control"] { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: var(--task-row-control-size); + height: var(--task-row-control-size); +} +.task-row-refreshed .task-row-disclosure { + width: var(--task-row-control-size); + height: var(--task-row-control-size); + min-height: 0; + padding: 0; + color: var(--task-row-title-color-read); +} +.task-row-refreshed .task-row-disclosure svg { + width: var(--task-row-caret-size); + height: var(--task-row-caret-size); +} +.task-row-refreshed [data-slot="task-row-tree-guide"] { + width: calc(var(--task-row-indent-step) - var(--task-row-gap)); +} +.task-row-refreshed [data-slot="task-row-tree-connector"] { + inset-block: 0; + left: var(--task-row-guide-offset); + width: var(--task-row-rule-width); + background: transparent; + pointer-events: none; +} +/* Match the space below the parent status glyph at the group's lower boundary. + Clip the paint so any disclosure gap stays centered within its original row. */ +.task-row-group > .task-row-refreshed:last-child [data-slot="task-row-tree-guide"]:first-child [data-slot="task-row-tree-connector"] { + clip-path: inset(0 0 var(--task-row-guide-end-gap) 0); +} +.task-row-refreshed [data-slot="task-row-tree-line"] { + background: var(--task-row-rule-color); +} +.task-row-refreshed [data-slot="task-row-tree-gap"] { + height: var(--task-row-control-size); +} +.task-row-refreshed [data-slot="task-row-content"] { + flex-direction: row; + align-items: center; + gap: var(--task-row-gap); +} +.task-row-refreshed [data-slot="task-row-title-cluster"] { align-items: center; } +.task-row-refreshed [data-slot="task-row-title"] { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + -webkit-line-clamp: unset; + font-size: var(--task-row-title-size); + line-height: var(--task-row-title-leading); + font-weight: var(--task-row-title-weight-read); + color: var(--task-row-title-color-read); +} +.task-row-refreshed[data-unread="true"] [data-slot="task-row-title"] { + font-weight: var(--task-row-title-weight-unread); + color: var(--task-row-title-color-active); +} +/* Each cell measures its own overflow. Row overlay links retain navigation; + only the cell under the pointer (or keyboard focus) reveals its contents. */ +.cell-marquee { + display: block; + min-width: 0; + width: 100%; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cell-marquee-content { + display: block; + pointer-events: none; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transform: translateX(0); + transition: none; +} +.cell-marquee-viewport { display: block; width: 100%; overflow: hidden; pointer-events: none; } +/* Mask content rather than painting a background-colored overlay, so text and + chips fade into every real row surface, including hover and selection. */ +.cell-marquee[data-truncated="true"] > .cell-marquee-viewport { + mask-image: linear-gradient(to right, var(--cell-marquee-mask-solid) calc(100% - var(--cell-marquee-fade-width)), transparent); +} +.cell-marquee[data-revealing="true"] > .cell-marquee-viewport { + mask-image: linear-gradient(to right, transparent, var(--cell-marquee-mask-solid) var(--cell-marquee-fade-width), var(--cell-marquee-mask-solid) calc(100% - var(--cell-marquee-fade-width)), transparent); +} +.cell-marquee[data-reveal-complete="true"] > .cell-marquee-viewport { + mask-image: linear-gradient(to right, transparent, var(--cell-marquee-mask-solid) var(--cell-marquee-fade-width)); +} +.cell-marquee[data-revealing="true"] { text-overflow: clip; } +.cell-marquee[data-revealing="true"] > .cell-marquee-viewport > .cell-marquee-content { + width: max-content; + overflow: visible; + transform: translateX(calc(var(--cell-marquee-overflow) * -1px)); + transition: transform calc(var(--cell-marquee-overflow) * var(--task-row-title-marquee-step-duration)) var(--task-row-title-marquee-ease) var(--task-row-title-marquee-delay); +} +.cell-marquee:focus-visible { outline: var(--task-row-focus-width) solid var(--task-row-focus-color); outline-offset: calc(var(--task-row-focus-width) * -1); } +@media (prefers-reduced-motion: reduce) { + .cell-marquee[data-revealing="true"] > .cell-marquee-viewport > .cell-marquee-content { transition: none; } + .cell-marquee[data-revealing="true"] > .cell-marquee-viewport { + mask-image: linear-gradient(to right, transparent, var(--cell-marquee-mask-solid) var(--cell-marquee-fade-width)); + } +} +.task-row-refreshed:is(:hover, :focus-within) [data-slot="task-row-title"] { + color: var(--task-row-title-color-active); +} +.task-row-refreshed [data-slot="task-row-trailing"] { gap: var(--task-row-gap); } +.task-row-refreshed [data-slot="task-row-identifier"] { + width: var(--task-row-identifier-width); + font-family: var(--task-row-font-mono, var(--font-mono)); +} +.task-row-refreshed [data-slot="task-row-timestamp"] { width: var(--task-row-time-width); } + +/* A narrow summary reuses the standalone row anatomy without a disclosure + gutter. Container breakpoints live here in the token/style layer: hide the + identifier below 28rem, then time below 20rem; never change type or row density. */ +.task-summary-list { + container: task-summary / inline-size; + min-width: 0; + margin: 0; + padding: 0; + list-style: none; +} +.task-summary-list > li + li { border-top: var(--task-row-rule-width) solid var(--task-row-rule-color); } +.task-summary-row [data-slot="task-row-leading-control"] { display: none; } +.task-summary-row [data-slot="task-row-trailing"] { display: flex; } +.task-summary-row [data-slot="task-row-title-cluster"] { min-width: 0; } +.activity-row-refreshed { display: flex; min-width: 0; text-decoration: none; } +.activity-row-refreshed:focus-visible { outline: var(--task-row-focus-width) solid var(--task-row-focus-color); outline-offset: calc(var(--task-row-focus-width) * -1); } +.activity-row-details:empty { display: none; } +.activity-row-details:not(:empty) { padding: 0 var(--task-row-padding-inline) var(--task-row-gap); } +.task-row-responsive-container { container: task-summary / inline-size; } +.task-row-responsive-container [data-slot="task-row-trailing"] { display: flex; } +@container task-summary (width < 28rem) { + .task-summary-row [data-slot="task-row-identifier"], + .task-row-responsive-container [data-slot="task-row-identifier"], + .task-row-responsive-container [data-slot="task-row-metadata"] { display: none; } +} +@container task-summary (width < 20rem) { + .task-summary-row [data-slot="task-row-timestamp"], + .task-row-responsive-container [data-slot="task-row-timestamp"] { display: none; } +} + +/* Complete task-list column contract. Header and row cells share the same + dimensions; horizontal scrolling preserves every field and title legibility. */ +:root, .dark { + --task-list-surface: var(--background); + --task-list-header-height: var(--task-row-height-comfortable); + --task-list-edge-fade-width: calc(var(--spacing) * 6); + --task-list-edge-fade-surface: var(--task-list-surface); + --task-list-controls-width: var(--task-row-height-comfortable); + --task-list-no-controls-space: 0px; + --task-list-controls-space: var(--task-list-no-controls-space); + --task-list-sort-icon-size: calc(var(--spacing) * 3); + --task-list-sort-gap: var(--spacing); + --task-list-sort-padding: var(--spacing); + --task-list-sort-padding-inline: calc(var(--spacing) * 2); + --task-list-sort-radius: calc(infinity * 1px); + --task-list-options-width: calc(var(--spacing) * 50); + --task-list-options-padding: var(--flyout-padding); + --task-list-options-heading-padding: var(--flyout-header-padding-inline); + --task-list-options-gap: var(--flyout-option-gap); + --task-list-options-item-height: var(--flyout-option-height); + --task-list-options-item-radius: var(--flyout-option-radius); + --task-list-options-check-size: var(--flyout-check-size); + --task-list-options-heading-size: var(--flyout-type-size); + --task-list-options-leading: var(--flyout-type-leading); + --task-list-options-surface: var(--task-list-surface); + --task-list-options-highlight: var(--muted-highlight); + --task-row-tag-background: var(--muted-highlight); + --task-row-tag-foreground: var(--muted-foreground); + --task-list-column-status: calc(var(--task-row-control-size) * 2 + var(--task-row-gap)); + --task-list-column-title-min: calc(var(--spacing) * 80); + --task-list-column-task: calc(var(--task-list-column-status) + var(--task-row-gap) + var(--task-list-column-title-min)); + --task-list-column-task-id: calc(var(--spacing) * 20); + --task-list-column-created-by: calc(var(--spacing) * 28); + --task-list-column-assignee: calc(var(--spacing) * 28); + --task-list-column-project: calc(var(--spacing) * 32); + --task-list-column-parent: calc(var(--spacing) * 36); + --task-list-column-tags: calc(var(--spacing) * 32); + --task-list-column-updated: calc(var(--spacing) * 26); + --task-list-column-worktree: calc(var(--spacing) * 38); + --task-list-detail-columns: var(--task-list-column-task-id) var(--task-list-column-created-by) var(--task-list-column-assignee) var(--task-list-column-project) var(--task-list-column-parent) var(--task-list-column-tags) var(--task-list-column-worktree) var(--task-list-column-updated); + --task-list-columns: var(--task-list-column-status) minmax(var(--task-list-column-title-min), 1fr) var(--task-list-detail-columns); + --task-list-min-width: calc(var(--task-list-column-status) + var(--task-list-column-title-min) + var(--task-list-column-task-id) + var(--task-list-column-created-by) + var(--task-list-column-assignee) + var(--task-list-column-project) + var(--task-list-column-parent) + var(--task-list-column-tags) + var(--task-list-column-updated) + var(--task-list-column-worktree) + var(--task-row-gap) * 9 + var(--task-row-padding-inline) * 2); +} +.task-row-design-preview { background: var(--task-list-surface); } +/* Proposal typography is shared by Storybook and the opt-in sandbox. Aliases + migrate existing production-component recipes without restyling the ordinary + app or stories outside the proposal theme. */ +html:has(.design-proposal-theme) { + --font-mono: var(--font-paper-mono); + --default-mono-font-family: var(--font-paper-mono); + --font-weight-medium: var(--font-weight-normal); + --font-weight-bold: var(--font-weight-semibold); + --text-micro: var(--text-xs); + --profile-popover-meta-line-height: calc(var(--text-xs) * var(--text-xs--line-height)); +} +/* Legacy micro utilities get the full xs size/leading recipe. New proposal + code uses text-xs directly; shared component defaults stay compatible. */ +html:has(.design-proposal-theme) [class~="text-(length:--text-micro)"] { + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); +} +/* Tailwind prose and preflight also contain literal medium/bolder weights. + Preserve explicit strong-heading rules and compact reasoning inheritance. */ +html:has(.design-proposal-theme) :where(b, strong) { font-weight: var(--font-weight-semibold); } +html:has(.design-proposal-theme) .paperclip-markdown :where(a, code, blockquote) { font-weight: var(--font-weight-normal); } + +/* Conversation proposal: two size/leading recipes, shared by saved threads, + synthetic previews and the composer. Page headers and other prose are outside + these explicit regions. Removing the proposal theme restores ordinary UI. */ +html:has(.design-proposal-theme) [data-conversation-typography] { + --conversation-human-foreground: #fff; + --conversation-body-size: var(--text-sm); + --conversation-body-leading: calc(var(--text-sm) * var(--text-sm--line-height)); + --conversation-meta-size: var(--text-xs); + --conversation-meta-leading: calc(var(--text-xs) * var(--text-xs--line-height)); + font-size: var(--conversation-body-size); + line-height: var(--conversation-body-leading); + color: var(--muted-foreground); +} +/* Collapse inherited contrast tiers without changing semantic status colors, + SVG icons, action-button variants or keyboard-focus treatments. */ +html:has(.design-proposal-theme) [data-conversation-typography] :where([class*="text-muted-foreground"], [class*="text-foreground"]):where(:not(svg, [data-slot="button"])) { + color: inherit; +} +html:has(.design-proposal-theme) [data-conversation-typography] :is( + [data-conversation-meta], [data-conversation-message], [data-testid="task-chat-agent-identity"], + [data-testid="task-chat-turn-summary"], [data-testid="task-chat-activity-phase"], + [data-testid="task-chat-tool-card"], [data-testid="task-chat-protocol-activity-row"], + [data-testid="task-chat-live-run-pill"], [data-testid="task-chat-current-activity"], + [data-testid="task-chat-activity-unavailable"], [data-testid="task-chat-collapsible-marker"], + [data-testid="task-chat-system-notice"], [data-testid="task-chat-runner-activity-detail"], + [data-testid="task-chat-composer-actions"] +) { + font-size: var(--conversation-meta-size); + line-height: var(--conversation-meta-leading); + color: var(--muted-foreground); +} +html:has(.design-proposal-theme) [data-conversation-typography] :is( + [data-testid="task-chat-agent-identity"], [data-testid="task-chat-human-identity"] +) { font-weight: var(--font-weight-normal); } +/* Nested text utilities and tool output share their owning role's rhythm. */ +html:has(.design-proposal-theme) [data-conversation-typography] :where( + [class~="text-xs"], [class~="text-sm"], [class~="text-(length:--text-micro)"], + [class~="leading-relaxed"], [class~="leading-4"], [class~="leading-none"] +):where(:not([data-slot="button"])) { + font-size: inherit; + line-height: inherit; +} +html:has(.design-proposal-theme) [data-conversation-typography] :where( + [data-conversation-text], [data-testid="task-chat-thinking"] .paperclip-markdown, + [data-testid="task-chat-composer-input"], + [data-testid="task-chat-live-run-pill"] > span:first-child, + [data-testid="task-chat-turn-status-header"] +) { + font-size: var(--conversation-body-size); + line-height: var(--conversation-body-leading); + color: var(--muted-foreground); +} +html:has(.design-proposal-theme) [data-conversation-typography] [data-conversation-text="final"] { color: var(--foreground); } +html:has(.design-proposal-theme) [data-conversation-typography] [data-conversation-text="human"] { color: var(--conversation-human-foreground); } +html:has(.design-proposal-theme) [data-conversation-typography] .paperclip-markdown { + font-size: inherit; + line-height: inherit; + color: inherit; +} +html:has(.design-proposal-theme) [data-conversation-typography] .paperclip-markdown :is(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, a, code, strong, b, em, th, td) { + font-size: inherit; + line-height: inherit; + color: inherit; + letter-spacing: normal; +} +html:has(.design-proposal-theme) [data-conversation-typography] .paperclip-markdown :is(h1, h2, h3, h4, h5, h6) { font-weight: var(--font-weight-normal); } +html:has(.design-proposal-theme) [data-conversation-typography] .paperclip-markdown :is(strong, b) { font-weight: var(--font-weight-semibold); } +/* Existing shared code-block rules use !important; retain their layout while + allowing authored message and diagnostic roles to own size and contrast. */ +html:has(.design-proposal-theme) [data-conversation-typography] .paperclip-markdown pre { + font-size: inherit !important; + line-height: inherit; + color: inherit !important; +} +html:has(.design-proposal-theme) [data-conversation-typography] .paperclip-markdown :is(a, a:hover, a:focus-visible) { color: inherit; } +html:has(.design-proposal-theme) [data-conversation-typography] .shimmer-text { + --shimmer-base: var(--foreground); + --shimmer-highlight: color-mix(in oklch, var(--foreground) 35%, transparent); + /* Two identical waves make the endpoints coincide without a blank dwell. */ + background-image: linear-gradient(90deg, var(--shimmer-base) 0%, var(--shimmer-highlight) 25%, var(--shimmer-base) 50%, var(--shimmer-highlight) 75%, var(--shimmer-base) 100%); + animation-duration: var(--motion-conversation-tool-shimmer); +} +/* One text-clipped background spans both Inter and Paper Mono. Descendants + inherit its text fill, rather than starting a separate sweep for each font. + Removing the lifecycle hook restores ordinary muted metadata and hover. */ +html:has(.design-proposal-theme) [data-conversation-typography] [data-conversation-tool-running="true"] { + color: var(--foreground); + --shimmer-base: var(--foreground); + --shimmer-highlight: color-mix(in oklch, var(--foreground) 35%, transparent); + background-image: linear-gradient(90deg, var(--shimmer-base) 0%, var(--shimmer-highlight) 25%, var(--shimmer-base) 50%, var(--shimmer-highlight) 75%, var(--shimmer-base) 100%); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer-text-slide var(--motion-conversation-tool-shimmer) linear infinite; +} +html:has(.design-proposal-theme) [data-conversation-typography] [data-conversation-tool-running="true"] > span { + background: none; + animation: none; + -webkit-text-fill-color: inherit; +} + + +/* Agent identity uses the body size with foreground semibold emphasis. */ +html:has(.design-proposal-theme) [data-conversation-typography] :is([data-testid="task-chat-agent-identity"], [data-testid="task-chat-agent-identity"] > span:last-child) { + font-size: var(--conversation-body-size); + line-height: var(--conversation-body-leading); + font-weight: var(--font-weight-semibold); + color: var(--foreground); +} +/* Restore the neutral interactive metadata treatment suppressed by contrast + normalization. Preserve semantic status accents and action-button variants. */ +html:has(.design-proposal-theme) [data-conversation-typography] :is( + button[data-testid="task-chat-turn-summary"], + button[data-testid="task-chat-activity-phase-toggle"], + [data-activity-item-id] > button, + [data-testid="task-chat-tool-card"] > button, + [data-testid="task-chat-thinking"] > button, + [data-testid="task-chat-protocol-activity-row"] button[aria-expanded], + [data-testid="task-chat-system-notice"] button[aria-expanded], + [data-testid="task-chat-collapsible-marker"] button[aria-expanded] +):is(:hover, :focus-visible):not(:disabled) { + color: var(--foreground); + background-color: color-mix(in srgb, var(--muted) 40%, transparent); + border-radius: var(--radius-sm); +} +@keyframes conversation-word-in { + from { opacity: var(--conversation-word-start-opacity); } + to { opacity: 1; } +} +.conversation-handoff-activity { + display: grid; + grid-template-rows: 1fr; + opacity: 1; + transition: opacity var(--motion-conversation-handoff-fade) var(--motion-ease-out), + grid-template-rows var(--motion-conversation-handoff-fold) var(--motion-ease-standard) var(--motion-conversation-handoff-fade); +} +.conversation-handoff-activity[data-collapsing="true"] { + grid-template-rows: 0fr; + opacity: 0; +} +[data-handoff-state="settled"] [data-testid="task-chat-bubble-attached-turn"] { + animation: conversation-word-in var(--motion-conversation-handoff-fade) var(--motion-ease-out) both; +} +html:has(.design-proposal-theme) [data-conversation-typography] .conversation-stream-word { + animation: conversation-word-in var(--motion-conversation-word-fade) var(--motion-ease-out) both; +} +@media (prefers-reduced-motion: reduce) { + .conversation-handoff-activity { transition: none; } + [data-handoff-state="settled"] [data-testid="task-chat-bubble-attached-turn"] { animation: none; } + html:has(.design-proposal-theme) [data-conversation-typography] .conversation-stream-word { animation: none; } + html:has(.design-proposal-theme) [data-conversation-typography] [data-conversation-tool-running="true"] { + animation: none; + background-image: none; + -webkit-text-fill-color: currentColor; + } +} + +/* Paper color foundation, scoped to proposed stories. The other six semantic + colors already match Paper's dark tokens in .dark above. Scope at html so + Radix portals inherit the same values; removing the story marker restores + the existing production theme. Source: Paper Dark mode frame 3LXU-2. */ +html.dark:has(.design-proposal-theme) { + --background: #1a1a1a; + --muted-alt: #202020; + --card: var(--background); + --card-foreground: var(--muted-foreground); + --popover: var(--background); + --accent: var(--muted-highlight); + --input: var(--border); + /* Focus follows foreground independently of neutral pane-divider interactions. */ + --ring: var(--foreground); + --sidebar-ring: var(--ring); + --pane-resize-highlight: oklch(0.556 0 0); + --task-row-surface-hover: var(--muted); + background: var(--background); +} +/* Light proposals inherit production semantic colors. Proposal-only surface + names alias existing production tokens rather than introducing a palette. */ +html:not(.dark):has(.design-proposal-theme) { + --muted-alt: var(--secondary); + --design-sidebar-surface: var(--primary-sidebar-background); + --design-sidebar-item-foreground: color-mix(in oklab, var(--foreground) 80%, transparent); + --design-sidebar-item-highlight: var(--sidebar-accent); + --design-sidebar-item-active-foreground: var(--sidebar-accent-foreground); +} +.design-proposal-theme { color: var(--muted-foreground); } +html.dark:has(.design-proposal-theme) .pane-resize-handle { --ring: var(--pane-resize-highlight); } +html:has(.design-proposal-theme) :is(body, #storybook-root) { background: var(--background); } +html:has(.design-proposal-theme) [data-slot="input"] { background: var(--background-alt); border-color: var(--border); color: var(--muted-foreground); } +html:has(.design-proposal-theme) [data-slot="input"]:focus-visible { border-color: var(--ring); } +/* Bright edge with a softer outer halo, matching the production focus treatment. + Trigger wrappers replace Button's slot, so include those identities too. + Resize controls keep their separate neutral interaction color. */ +html:has(.design-proposal-theme) :where([data-slot="input"], [data-slot="textarea"], [data-slot="button"], [data-slot="tooltip-trigger"], [data-slot="dropdown-menu-trigger"], [data-slot="popover-trigger"], [data-slot="select-trigger"], [data-slot="checkbox"], [data-slot="tabs-trigger"], [role="tab"], select):focus-visible:where(:not([aria-invalid="true"], [data-variant="destructive"], .pane-resize-handle, .pane-resize-handle *, .design-task-search input)) { + --tw-ring-color: var(--focus-ring-halo); + --tw-ring-shadow: var(--focus-ring-shadow); + outline: var(--focus-ring-stroke-width) solid var(--ring); + outline-offset: calc(-1 * var(--focus-ring-stroke-width)); + box-shadow: var(--focus-ring-shadow); +} +html:has(.design-proposal-theme) :where(.task-row-link, .task-row-status-link, .cell-marquee, .activity-row-refreshed, .task-list-sort-button, .design-page-content, .design-sidebar-recent-list, .design-sidebar a, .task-filter-search input):focus-visible { + outline: var(--focus-ring-stroke-width) solid var(--ring); + outline-offset: calc(-1 * var(--focus-ring-stroke-width)); + box-shadow: var(--focus-ring-shadow); +} +html:has(.design-proposal-theme) [data-slot="card"] { border-color: var(--border); } +html:has(.design-proposal-theme) [data-slot="card"][data-elevation="middle"] { background: var(--muted-alt); } +html:has(.design-proposal-theme) [data-slot="card"][data-elevation="high"] { background: var(--muted); } +html:has(.design-proposal-theme) [data-slot="card-title"] { color: var(--foreground); } +.task-list-columns-scroll { overflow-x: auto; isolation: isolate; } +.task-list-viewport, +.horizontal-scroll-viewport { position: relative; min-width: 0; background: var(--task-list-surface); } +.horizontal-scroll-area { overflow-x: auto; } +.horizontal-scroll-content { width: max-content; min-width: 100%; } +.task-list-viewport::before, +.task-list-viewport::after, +.horizontal-scroll-viewport::before, +.horizontal-scroll-viewport::after { + content: ""; + position: absolute; + inset-block: 0; + width: var(--task-list-edge-fade-width); + pointer-events: none; + z-index: 1; + opacity: 0; +} +.task-list-viewport::before, +.horizontal-scroll-viewport::before { + left: 0; + background: linear-gradient(to right, var(--task-list-edge-fade-surface), transparent); +} +.task-list-viewport::after, +.horizontal-scroll-viewport::after { + right: 0; + background: linear-gradient(to left, var(--task-list-edge-fade-surface), transparent); +} +.task-list-viewport[data-overflow-left="true"]::before, +.task-list-viewport[data-overflow-right="true"]::after, +.horizontal-scroll-viewport[data-overflow-left="true"]::before, +.horizontal-scroll-viewport[data-overflow-right="true"]::after { opacity: 1; } +.task-list-columns-content { min-width: var(--task-list-min-width); } +.task-list-column-header { + display: grid; + grid-template-columns: var(--task-list-columns); + align-items: center; + gap: var(--task-row-gap); + height: var(--task-list-header-height); + padding-inline: var(--task-row-padding-inline) calc(var(--task-row-padding-inline) + var(--task-list-controls-space)); + font-size: var(--task-row-title-size); + line-height: var(--task-row-title-leading); + font-weight: var(--font-weight-medium); + color: var(--muted-foreground); +} +.task-row-refreshed[data-columns="all"] [data-slot="task-row-trailing"] { display: contents; } +.task-row-refreshed[data-columns="all"] { + display: grid; + grid-template-columns: var(--task-list-columns); + grid-template-rows: minmax(0, 1fr); + padding-inline-end: calc(var(--task-row-padding-inline) + var(--task-list-controls-space)); +} +.task-row-refreshed[data-columns="all"] [data-slot="task-row-leading"] { grid-column: var(--task-list-task-start); grid-row: 1; } +.task-row-refreshed[data-columns="all"] [data-slot="task-row-content"] { + grid-column: calc(var(--task-list-task-start) + 1); + grid-row: 1; + padding-inline-start: calc(var(--task-row-depth) * var(--task-row-indent-step)); +} +.task-row-refreshed[data-title-hidden="true"] [data-slot="task-row-content"] { display: none; } +.task-row-refreshed[data-title-hidden="true"] [data-slot="task-row-leading"] { flex: 1; } +.task-row-refreshed[data-columns="all"] [data-slot="task-row-trailing"]:empty { display: none; } +.task-row-columns { + display: contents; + font-size: var(--task-row-title-size); + line-height: var(--task-row-title-leading); + color: var(--muted-foreground); +} +.task-row-column { + grid-row: 1; + align-self: center; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-row-column > * { max-width: 100%; } +.task-row-columns [data-column="tags"] [data-slot="badge"] { + color: var(--task-row-tag-foreground); + background-color: var(--task-row-tag-background); + border-color: var(--task-row-tag-background); +} +.task-row-columns :is([data-column="taskId"], [data-column="worktree"]), +.task-row-columns [data-column="parentTask"] .font-mono { + font-family: var(--task-row-font-mono); +} + +.task-list-column-header [role="columnheader"] { min-width: 0; } +.task-list-column-header [data-dragging="true"] { position: relative; z-index: 10; } +.task-list-column-header .task-list-sort-button { + display: inline-flex; + align-items: center; + gap: var(--task-list-sort-gap); + margin-inline-start: calc(var(--task-list-sort-padding-inline) * -1); + padding: var(--task-list-sort-padding) var(--task-list-sort-padding-inline); + height: calc(var(--task-row-title-leading) + var(--task-list-sort-padding) * 2); + border-radius: var(--task-list-sort-radius); + font-size: var(--task-row-title-size); + line-height: var(--task-row-title-leading); + color: var(--muted-foreground); + white-space: nowrap; + cursor: pointer; +} +.task-list-sort-button svg { width: var(--task-list-sort-icon-size); height: var(--task-list-sort-icon-size); flex-shrink: 0; } +.task-list-column-header [data-column="task"] { + padding-inline-start: calc(var(--task-list-column-status) + var(--task-row-gap)); +} +.task-list-sort-button[data-active="true"] { background: var(--muted-highlight); color: var(--foreground); } +.task-list-sort-button:hover { background: var(--muted-highlight); color: var(--foreground); } +.task-list-sort-button[data-reorderable="true"] { cursor: grab; touch-action: pan-y; } +.task-list-column-header [data-dragging="true"] .task-list-sort-button { cursor: grabbing; } +.task-list-sort-button:focus-visible { + outline: var(--task-row-focus-width) solid var(--task-row-focus-color); + outline-offset: var(--task-row-rule-width); + box-shadow: none; +} +.task-list-header-controls { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + width: var(--task-list-controls-width); + height: var(--task-list-header-height); + background: var(--task-list-surface); +} +.task-list-settings-trigger { color: var(--muted-foreground); } +.task-list-settings-trigger:is(:hover, [data-state="open"]) { background: var(--muted-highlight); color: var(--foreground); } +.task-list-options { + width: var(--task-list-options-width); + padding: 0; + border: var(--task-row-rule-width) solid var(--border); + border-radius: var(--radius-lg); + background: var(--task-list-options-surface); + box-shadow: none; + font-size: var(--task-row-title-size); + line-height: var(--task-list-options-leading); +} +.task-list-options-heading { + padding: var(--task-list-options-padding) var(--task-list-options-heading-padding); + border-bottom: var(--task-row-rule-width) solid var(--border); + font-size: var(--task-list-options-heading-size); + line-height: var(--task-list-options-leading); + font-weight: var(--font-weight-semibold); + color: var(--foreground); +} +.task-list-options-items { display: flex; flex-direction: column; gap: var(--task-list-options-gap); padding: var(--task-list-options-padding); } +.task-list-options-item { + height: var(--task-list-options-item-height); + flex-shrink: 0; + padding: var(--task-list-options-gap) var(--task-list-options-padding); + padding-inline-end: calc(var(--task-list-options-padding) + var(--task-list-options-check-size) + var(--task-row-gap)); + border-radius: var(--task-list-options-item-radius); + font-size: var(--task-row-title-size); + line-height: var(--task-list-options-leading); + font-weight: var(--font-weight-normal); + color: var(--muted-foreground); +} +.task-list-options-item > span { left: auto; right: var(--task-list-options-padding); } +.task-list-options-item svg { width: var(--task-list-options-check-size); height: var(--task-list-options-check-size); } +.task-list-options-item[data-highlighted] { background: var(--task-list-options-highlight); color: var(--foreground); } +.task-list-options-reset { + height: var(--task-list-options-item-height); + flex-shrink: 0; + justify-content: center; + padding: var(--task-list-options-gap) var(--task-list-options-padding); + border: 0; + outline: var(--task-row-rule-width) solid var(--task-list-options-highlight); + border-radius: var(--task-list-options-item-radius); + background: var(--muted); + color: var(--foreground); + font-size: var(--task-list-options-heading-size); + line-height: var(--task-list-options-leading); + font-weight: var(--font-weight-semibold); +} +.task-list-options-reset[data-highlighted] { background: var(--task-list-options-highlight); } + +/* One opt-in appearance for the existing Radix dropdown and popover primitives. + Context follows portals; default production surfaces retain their styling. */ +:root, .dark { + --flyout-surface: var(--background); + --flyout-border: var(--border); + --flyout-highlight: var(--muted-highlight); + --flyout-foreground: var(--foreground); + --flyout-item-foreground: var(--muted-foreground); + --flyout-radius: var(--radius-lg); + --flyout-width: calc(var(--spacing) * 50); + --flyout-padding: calc(var(--spacing) * 3); + --flyout-header-padding-inline: calc(var(--spacing) * 6); + --flyout-option-gap: var(--spacing); + --flyout-option-height: var(--task-row-height-compact); + --flyout-option-radius: var(--spacing); + --flyout-check-size: var(--task-row-caret-size); + --flyout-type-size: var(--text-sm); + --flyout-type-leading: calc(var(--spacing) * 5); + --shadow-flyout: 0 4px 16px rgb(0 0 0 / 20%); + --task-preview-width: calc(var(--spacing) * 70); + --task-preview-padding: calc(var(--spacing) * 4); + --task-preview-gap: calc(var(--spacing) * 3); + --task-preview-meta-gap: calc(var(--spacing) * 2); + --task-preview-meta-size: var(--text-xs); + --task-preview-meta-leading: calc(var(--spacing) * 4); + --task-preview-title-size: var(--text-sm); + --task-preview-content-leading: calc(var(--spacing) * 4.5); + --task-preview-title-lines: 2; + --task-preview-description-lines: 3; +} +[data-flyout-appearance="refreshed"] { + min-height: 0; + padding: 0; + border: var(--task-row-rule-width) solid var(--flyout-border); + border-radius: var(--flyout-radius); + background: var(--flyout-surface); + color: var(--flyout-item-foreground); + box-shadow: var(--shadow-flyout); + font-size: var(--flyout-type-size); + line-height: var(--flyout-type-leading); +} +/* Task previews share the popover surface and shadow, but their content is a + single padded card rather than a menu with separately padded sections. */ +[data-quicklook][data-flyout-appearance="refreshed"] { + width: var(--task-preview-width); + max-width: var(--radix-popover-content-available-width, 100vw); + padding: var(--task-preview-padding); +} +.task-hover-card { display: flex; flex-direction: column; gap: var(--task-preview-gap); } +.task-hover-card [data-slot="task-preview-meta"], +.task-hover-card [data-slot="task-preview-identity"] { gap: var(--task-preview-meta-gap); } +.task-hover-card [data-slot="task-preview-id"], +.task-hover-card [data-slot="task-preview-time"] { + color: var(--muted-foreground); + font-size: var(--task-preview-meta-size); + line-height: var(--task-preview-meta-leading); +} +.task-hover-card [data-slot="task-preview-title"] { + color: var(--foreground); + font-size: var(--task-preview-title-size); + font-weight: var(--font-weight-semibold); + line-height: var(--task-preview-content-leading); + -webkit-line-clamp: var(--task-preview-title-lines); +} +.task-hover-card [data-slot="task-preview-description"] { + color: var(--muted-foreground); + font-size: var(--task-preview-meta-size); + line-height: var(--task-preview-content-leading); + -webkit-line-clamp: var(--task-preview-description-lines); +} +[data-flyout-appearance="refreshed"] [data-flyout-header] { + height: auto; + padding: var(--flyout-padding) var(--flyout-header-padding-inline); + border: 0; + border-bottom: var(--task-row-rule-width) solid var(--flyout-border); + color: var(--flyout-foreground); + font-size: var(--flyout-type-size); + line-height: var(--flyout-type-leading); + font-weight: var(--font-weight-semibold); +} +[data-flyout-appearance="refreshed"] [data-flyout-header] [data-slot="dropdown-menu-label"] { padding: 0; font: inherit; color: inherit; } +[data-flyout-appearance="refreshed"] [data-flyout-header] > button { color: var(--flyout-item-foreground); } +[data-flyout-appearance="refreshed"] [data-flyout-header] > button:hover { background: var(--flyout-highlight); color: var(--flyout-foreground); } +[data-flyout-appearance="refreshed"] [data-flyout-profile] { padding: var(--flyout-header-padding-inline); } +[data-flyout-appearance="refreshed"] [data-flyout-profile] > a:hover { background: var(--flyout-highlight); } +[data-flyout-appearance="refreshed"] [data-flyout-options] { + display: flex; + flex: none; + flex-direction: column; + gap: var(--flyout-option-gap); + padding: var(--flyout-padding); + border-top: 0; +} +[data-flyout-appearance="refreshed"] [data-flyout-options] + [data-flyout-options] { border-top: var(--task-row-rule-width) solid var(--flyout-border); } +[data-flyout-appearance="refreshed"] :is([data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-checkbox-item"], [data-slot="dropdown-menu-radio-item"], [data-flyout-options] > a, [data-flyout-options] > button) { + min-height: var(--flyout-option-height); + border-radius: var(--flyout-option-radius); + padding-inline: var(--flyout-padding); + color: var(--flyout-item-foreground); + font-size: var(--flyout-type-size); + line-height: var(--flyout-type-leading); + font-weight: var(--font-weight-normal); +} +[data-flyout-appearance="refreshed"] :is([data-slot="dropdown-menu-checkbox-item"], [data-slot="dropdown-menu-radio-item"]) { padding-inline-end: calc(var(--flyout-padding) + var(--flyout-check-size) + var(--flyout-option-gap) * 2); } +[data-flyout-appearance="refreshed"] [data-flyout-options] > :is(a, button) { height: var(--flyout-option-height); } +[data-flyout-appearance="refreshed"] :is([data-slot="dropdown-menu-checkbox-item"], [data-slot="dropdown-menu-radio-item"]) > span { left: auto; right: var(--flyout-padding); } +[data-flyout-appearance="refreshed"] :is([data-slot="dropdown-menu-checkbox-item"], [data-slot="dropdown-menu-radio-item"]) svg { width: var(--flyout-check-size); height: var(--flyout-check-size); } +[data-flyout-appearance="refreshed"] :is([data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-checkbox-item"], [data-slot="dropdown-menu-radio-item"], [data-flyout-options] > a, [data-flyout-options] > button) :is(.text-muted-foreground, .text-foreground, svg) { color: inherit; } +[data-flyout-appearance="refreshed"] :is([data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-checkbox-item"], [data-slot="dropdown-menu-radio-item"], [data-flyout-options] > a, [data-flyout-options] > button):is([data-highlighted], :hover, :focus-visible) { + background: var(--flyout-highlight); + color: var(--flyout-foreground); +} +[data-flyout-appearance="refreshed"] [data-flyout-options] .task-list-options-reset { font-weight: var(--font-weight-semibold); } + +/* Full-page proposal: compose the reviewed collection with the shared sidebar + shell and controls. These values stay opt-in until page-level approval. */ +:root, .dark { + --design-page-height: 100dvh; + --design-page-header-height: calc(var(--spacing) * 15); + --design-page-aside-width: var(--sz-320px); + --design-page-aside-max-width: 30%; + --design-page-aside-min-width: var(--sz-260px); + --design-page-main-min-width: var(--sz-320px); + --design-page-aside-resize-step: 16px; + --design-page-aside-resize-hit-width: calc(var(--spacing) * 2); + --design-page-inset: calc(var(--spacing) * 6); + --design-page-section-gap: calc(var(--spacing) * 8); + --design-page-toolbar-gap: calc(var(--spacing) * 4); + --design-page-control-height: var(--task-row-height-comfortable); + --design-page-control-padding: calc(var(--spacing) * 3); + --design-page-control-radius: var(--radius-md); + --design-page-search-width: calc(var(--spacing) * 68); + --design-page-search-min: calc(var(--spacing) * 40); + --design-page-search-surface: var(--background-alt); + --design-sidebar-surface: var(--muted); /* Paper: --dark-color-muted. */ + --design-sidebar-border: var(--border); /* Paper: --dark-color-border. */ + --design-sidebar-item-foreground: var(--muted-foreground); + --design-sidebar-item-highlight: var(--muted-highlight); + --design-sidebar-item-active-foreground: var(--foreground); + --design-page-control-surface: color-mix(in oklab, var(--foreground) 4%, var(--task-list-surface)); + /* Opaque so the search field's darker fill doesn't change the visible border. */ + --design-page-control-border: color-mix(in srgb, var(--foreground) 15%, var(--design-page-control-surface)); + --design-page-icon-size: calc(var(--spacing) * 4); + --design-page-control-gap: calc(var(--spacing) * 2); + --design-page-action-gap: var(--spacing); + --design-page-type-size: var(--text-sm); + --design-page-type-leading: calc(var(--spacing) * 5); + --design-page-heading-tracking: 0.05em; + --design-sidebar-inset: calc(var(--spacing) * 3); + --design-sidebar-item-inset: calc(var(--spacing) * 2); + --design-sidebar-label-leading: 1.5; + --design-sidebar-item-height: calc(var(--text-sm) * var(--design-sidebar-label-leading) + var(--spacing) * 3); + --design-sidebar-item-gap: calc(var(--spacing) * 0.5); + --design-sidebar-heading-tracking: 0.1em; + --design-sidebar-label-size: var(--text-sm); + --design-page-focus-width: var(--task-row-focus-width); +} +.design-page-shell { display: flex; position: relative; height: var(--design-page-height); overflow: hidden; color: var(--foreground); } +.design-page-main { display: flex; flex: 1; flex-direction: column; min-width: 0; background: var(--task-list-surface); } +.design-page-aside { position: relative; display: flex; flex-direction: column; flex: 0 0 clamp(var(--design-page-aside-min-width), var(--design-page-aside-user-width, min(var(--design-page-aside-width), var(--design-page-aside-max-width))), var(--design-page-aside-available-width, 100%)); min-width: 0; background: var(--background); border-inline-start: var(--task-row-rule-width) solid var(--border); } +.design-page-aside-resize { left: calc(var(--design-page-aside-resize-hit-width) / -2); width: var(--design-page-aside-resize-hit-width); } +.design-page-shell[data-aside-maximized="true"] > .design-page-main { display: none; } +.design-page-shell[data-aside-maximized="true"] > .design-page-aside { flex: 1; } +.design-page-header-actions { display: flex; align-items: center; margin-inline-start: auto; } +.design-page-header.design-page-aside-header { min-width: 0; gap: var(--spacing); padding-inline: calc(var(--spacing) * 2); } +.design-page-aside-tabs { display: flex; flex: 1; align-self: stretch; min-width: 0; } +.design-page-aside-controls { display: flex; flex-shrink: 0; align-items: center; gap: var(--spacing); } +.design-page-header { display: flex; align-items: center; height: var(--design-page-header-height); flex-shrink: 0; padding-inline: var(--design-page-inset); border-bottom: var(--task-row-rule-width) solid var(--border); } +.design-page-header :is(h1, h2) { font-size: var(--design-page-type-size); line-height: var(--design-page-type-leading); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: var(--design-page-heading-tracking); } +.design-page-content { display: flex; flex: 1; flex-direction: column; gap: var(--design-page-section-gap); min-height: 0; overflow-y: auto; padding: var(--design-page-inset); outline: none; } +.design-page-aside .design-page-content { overscroll-behavior-y: contain; } +.design-page-aside .design-page-content:focus-visible { outline: var(--design-page-focus-width) solid var(--ring); outline-offset: calc(-1 * var(--design-page-focus-width)); } +.design-page-skip-link { position: absolute; z-index: var(--z-60); top: var(--design-page-inset); left: var(--design-page-inset); padding: var(--design-page-control-padding); border-radius: var(--design-page-control-radius); background: var(--foreground); color: var(--background); transform: translateY(calc(-1 * var(--design-page-header-height) * 2)); } +.design-page-skip-link:focus { transform: none; } +/* Keep the muted fill out of the translucent divider. The divider composites + over the app background, matching the main-pane header and pane boundaries. */ +.design-sidebar { display: flex; flex-direction: column; height: 100%; min-height: 0; border-inline-end: var(--task-row-rule-width) solid var(--design-sidebar-border); background: var(--design-sidebar-surface); background-clip: padding-box; } +.design-sidebar-brand { display: flex; align-items: center; gap: var(--design-page-control-gap); flex-shrink: 0; height: var(--design-page-header-height); padding-inline: var(--design-sidebar-inset); font-size: var(--design-page-type-size); font-weight: var(--font-weight-semibold); } +.design-sidebar-search { flex-shrink: 0; color: var(--muted-foreground); } +.design-sidebar-search svg { width: var(--design-page-icon-size); height: var(--design-page-icon-size); } +.design-sidebar-search:hover { background: var(--muted-highlight); color: var(--foreground); } +.design-sidebar-navigation { display: flex; flex: 1; flex-direction: column; min-height: 0; gap: var(--design-page-toolbar-gap); overflow-y: auto; padding: var(--design-sidebar-item-inset) var(--design-sidebar-inset); } +.design-sidebar-primary { display: flex; flex-direction: column; gap: var(--design-sidebar-item-gap); } +.design-sidebar-navigation [class~="group/sidebar-section"] > div { min-height: 0; } +/* Navigation and streamlined properties intentionally share this exact label + recipe: sans, nano, normal, uppercase, 150% leading and 0.1em tracking. */ +html:has(.design-proposal-theme) :is([data-slot="sidebar-group-label"], [data-slot="property-section-label"][data-streamlined="true"]), +html:has(.design-proposal-theme) .design-navigation-group-label { + font-family: var(--font-sans); + font-size: var(--text-nano); + font-weight: var(--font-weight-normal); + line-height: var(--design-sidebar-label-leading); + letter-spacing: var(--design-sidebar-heading-tracking); + text-transform: uppercase; + color: color-mix(in oklab, var(--muted-foreground) 60%, transparent); +} +.design-sidebar .design-sidebar-item { min-height: var(--design-sidebar-item-height); margin-inline: var(--design-sidebar-item-inset); padding-inline: var(--design-sidebar-item-inset); color: var(--design-sidebar-item-foreground); font-size: var(--design-sidebar-label-size); line-height: var(--design-sidebar-label-leading); } +.design-sidebar .design-sidebar-item:is(:hover, [aria-current="page"]) { background: var(--design-sidebar-item-highlight); color: var(--design-sidebar-item-active-foreground); } +.design-sidebar-new-task { display: flex; justify-content: flex-start; width: calc(100% - var(--design-sidebar-item-inset) * 2); height: var(--design-sidebar-item-height); margin-inline: var(--design-sidebar-item-inset); padding-inline: var(--design-sidebar-item-inset); gap: calc(var(--spacing) * 2.5); font-size: var(--design-sidebar-label-size); line-height: var(--design-sidebar-label-leading); font-weight: var(--font-weight-medium); color: var(--design-sidebar-item-foreground); } +.design-sidebar-new-task svg { width: var(--design-page-icon-size); height: var(--design-page-icon-size); } +.design-sidebar-new-task:hover { background: var(--design-sidebar-item-highlight); color: var(--design-sidebar-item-active-foreground); } +.design-sidebar-recents .design-sidebar-item { font-weight: var(--font-weight-normal); } +.design-sidebar-navigation > :not(.design-sidebar-recents) { flex-shrink: 0; } +.design-sidebar-recents { display: flex; flex: 1; min-height: 0; } +.design-sidebar-recents > div { display: flex; flex: 1; flex-direction: column; min-width: 0; min-height: 0; } +.design-sidebar-recents > div > :first-child { flex-shrink: 0; } +.design-sidebar-recents > div > :last-child { flex: 1; min-height: 0; } +.design-sidebar-recent-list { display: flex; flex-direction: column; gap: var(--design-sidebar-item-gap); min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; } +.design-sidebar-recent-list:focus-visible { outline: var(--design-page-focus-width) solid var(--ring); outline-offset: calc(-1 * var(--design-page-focus-width)); } +.design-sidebar-recent-list .design-sidebar-item { flex-shrink: 0; } +.design-sidebar-account { flex-shrink: 0; } +.design-sidebar-account [aria-label="Open account menu"] { font-size: var(--design-sidebar-label-size); line-height: var(--design-sidebar-label-leading); color: var(--foreground); } +.design-sidebar-account [data-slot="avatar-fallback"] { background: var(--muted-highlight); color: var(--muted-foreground); } +.design-page-toolbar { display: flex; align-items: center; flex-wrap: wrap; flex-shrink: 0; gap: var(--design-page-toolbar-gap); } +.design-page-toolbar .design-toolbar-button, +.design-toolbar-filter > button { height: var(--design-page-control-height); border-radius: var(--design-page-control-radius); padding-inline: var(--design-page-control-padding); gap: var(--design-page-control-gap); font-size: var(--design-page-type-size); line-height: var(--design-page-type-leading); box-shadow: none; } +.design-page-toolbar .design-toolbar-button:not(.design-new-task), +.design-toolbar-filter > button { background: var(--design-page-control-surface); border-color: var(--design-page-control-border); color: var(--muted-foreground); } +.design-page-toolbar .design-toolbar-button:is(:hover, [aria-pressed="true"], [data-active="true"], [data-state="open"]), +.design-toolbar-filter > button:hover { background: var(--muted-highlight); color: var(--foreground); } +.design-page-toolbar .design-toolbar-button svg, +.design-toolbar-filter > button svg, +.design-task-search > svg { width: var(--design-page-icon-size); height: var(--design-page-icon-size); flex-shrink: 0; } +.design-page-toolbar .design-toolbar-icon, +.design-toolbar-filter > button { width: var(--design-page-control-height); padding: 0; } +.design-page-toolbar .design-view-trigger { justify-content: space-between; min-width: calc(var(--spacing) * 28); } +.design-task-search { display: flex; align-items: center; gap: var(--design-page-control-gap); width: var(--design-page-search-width); min-width: var(--design-page-search-min); height: var(--design-page-control-height); padding-inline: var(--design-page-control-padding); border: var(--task-row-rule-width) solid var(--design-page-control-border); border-radius: var(--design-page-control-radius); color: var(--muted-foreground); background: var(--design-page-search-surface); } +.design-task-search:focus-within { border-color: var(--ring); outline: none; box-shadow: var(--focus-ring-shadow); } +.design-task-search input { min-width: 0; height: 100%; padding: 0; border: none; border-radius: 0; background: transparent; color: var(--foreground); box-shadow: none; font-size: var(--design-page-type-size); line-height: var(--design-page-type-leading); } +.design-task-search input:focus-visible { outline: none; box-shadow: none; } +.design-task-search input::-webkit-search-cancel-button { display: none; } +.design-view-toggle { display: flex; border: var(--task-row-rule-width) solid var(--design-page-control-border); border-radius: var(--design-page-control-radius); } +.design-view-toggle .design-toolbar-button { height: calc(var(--design-page-control-height) - var(--task-row-rule-width) * 2); border-radius: 0; background: transparent; } +.design-view-toggle .design-toolbar-button:first-child { border-start-start-radius: var(--design-page-control-radius); border-end-start-radius: var(--design-page-control-radius); } +.design-view-toggle .design-toolbar-button:last-child { border-start-end-radius: var(--design-page-control-radius); border-end-end-radius: var(--design-page-control-radius); } +.design-view-toggle .design-toolbar-button:focus-visible { position: relative; z-index: var(--z-1); } +.design-toolbar-actions { display: flex; align-items: center; gap: var(--design-page-action-gap); margin-inline-start: auto; } +.design-page-toolbar .design-new-task { margin-inline-start: var(--design-page-toolbar-gap); background: var(--foreground); color: var(--background); font-weight: var(--font-weight-semibold); } +.design-page-toolbar .design-new-task:hover { background: color-mix(in oklab, var(--foreground) 90%, var(--task-list-surface)); color: var(--background); } +.design-task-results { margin-block-end: calc(-1 * var(--design-page-toolbar-gap)); font-size: var(--design-page-type-size); line-height: var(--design-page-type-leading); color: var(--muted-foreground); } +.design-task-list-panel { min-width: 0; } +.design-task-list-panel[hidden] { display: none; } +.design-task-board { min-width: 0; } + +/* Paper filter IA: five peer groups, with searchable option lists. Surface, + highlight and shadow inherit the shared refreshed popover appearance. */ +:root, .dark { + --task-filter-column-width: calc(var(--spacing) * 50); + --task-filter-column-gap: calc(var(--spacing) * 4); + --task-filter-panel-width: calc(var(--task-filter-column-width) * 5 + var(--task-filter-column-gap) * 4 + var(--flyout-padding) * 2 + var(--task-row-rule-width) * 2); + --task-filter-row-height: calc(var(--spacing) * 7); + --task-filter-row-gap: calc(var(--spacing) * 0.5); + --task-filter-row-padding: var(--spacing); + --task-filter-row-padding-inline: calc(var(--spacing) * 2); + --task-filter-icon-size: var(--task-row-control-size); + --task-filter-search-height: var(--task-row-height-compact); + --task-filter-search-icon-size: calc(var(--spacing) * 3); + --task-filter-search-inset: calc(var(--spacing) * 7); + --task-filter-label-dot-size: calc(var(--spacing) * 2.5); + --task-filter-checkbox-surface: color-mix(in srgb, var(--flyout-foreground) 4%, var(--flyout-surface)); +} +.task-filters-popover { + display: flex; + flex-direction: column; + width: var(--task-filter-panel-width); + max-width: calc(100vw - var(--flyout-padding) * 2); + max-height: min(var(--radix-popover-content-available-height, 100dvh), var(--sz-calc-9)); + overflow: hidden; +} +.task-filters-popover [data-flyout-header] { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; padding: var(--flyout-padding); } +.task-filters-popover [data-flyout-header] > button { height: var(--flyout-type-leading); padding-inline: var(--task-filter-row-padding-inline); font-size: var(--flyout-type-size); font-weight: var(--font-weight-normal); } +.task-filters-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--task-filter-column-width)), 1fr)); align-items: start; gap: var(--task-filter-column-gap); min-height: 0; padding: var(--flyout-padding); overflow-y: auto; overscroll-behavior: contain; } +.task-filter-group { display: flex; flex-direction: column; min-width: 0; gap: var(--task-filter-column-gap); } +.task-filter-group h3 { margin: 0; color: var(--flyout-foreground); font-size: var(--flyout-type-size); line-height: var(--flyout-type-leading); font-weight: var(--font-weight-semibold); } +.task-filter-group-body { display: flex; flex-direction: column; min-width: 0; gap: var(--flyout-padding); } +.task-filter-options { display: flex; flex-direction: column; gap: var(--task-filter-row-gap); } +.task-filter-option { display: flex; align-items: center; min-width: 0; min-height: var(--task-filter-row-height); padding: var(--task-filter-row-padding) var(--task-filter-row-padding-inline); gap: var(--task-filter-row-padding-inline); border-radius: var(--flyout-option-radius); color: var(--flyout-item-foreground); cursor: pointer; font-size: var(--flyout-type-size); line-height: var(--flyout-type-leading); } +.task-filter-option:is(:hover, :focus-within) { background: var(--flyout-highlight); color: var(--flyout-foreground); } +.task-filter-option-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.task-filter-option [data-slot="checkbox"] { width: var(--task-filter-icon-size); height: var(--task-filter-icon-size); border-color: var(--flyout-border); background: var(--task-filter-checkbox-surface); color: inherit; box-shadow: none; } +.task-filter-option [data-slot="checkbox"][data-state="checked"] { background: var(--flyout-highlight); } +.task-filter-icon { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: var(--task-filter-icon-size); height: var(--task-filter-icon-size); } +.task-filter-icon > svg { width: var(--task-filter-icon-size); height: var(--task-filter-icon-size); } +.task-filter-label-dot { flex-shrink: 0; width: var(--task-filter-label-dot-size); height: var(--task-filter-label-dot-size); border-radius: calc(var(--task-filter-label-dot-size) / 2); background: var(--task-filter-label-color); } +.task-filter-search { position: relative; } +.task-filter-search > svg { position: absolute; left: var(--task-filter-row-padding-inline); top: 50%; transform: translateY(-50%); width: var(--task-filter-search-icon-size); height: var(--task-filter-search-icon-size); color: var(--flyout-item-foreground); pointer-events: none; } +.task-filter-search input { height: var(--task-filter-search-height); min-width: 0; padding-inline: var(--task-filter-search-inset) var(--flyout-padding); border: var(--task-row-rule-width) solid var(--flyout-border); border-radius: var(--radius-md); background: var(--background-alt); color: var(--flyout-foreground); font-size: var(--flyout-type-size); line-height: var(--flyout-type-leading); box-shadow: none; } +.task-filter-search input:focus-visible { outline: var(--task-row-focus-width) solid var(--ring); outline-offset: var(--task-row-rule-width); } +@media (pointer: coarse) { + .task-filter-search input { font-size: var(--text-base); } +} +.task-filter-empty { padding: var(--task-filter-row-padding) var(--task-filter-row-padding-inline); color: var(--flyout-item-foreground); font-size: var(--flyout-type-size); line-height: var(--flyout-type-leading); } +.task-list-group-heading { margin-block: var(--design-page-toolbar-gap) var(--design-page-control-gap); } + +/* Canonical collection toolbars: shared geometry, opt-in proposal styling. + Search uses ToolbarSearch; only Tasks puts its view/heading before search. */ +:root { + --collection-toolbar-height: var(--design-page-control-height); + --collection-toolbar-gap: calc(var(--spacing) * 2); + --collection-toolbar-radius: var(--design-page-control-radius); + --collection-toolbar-border: var(--design-page-control-border); + --collection-toolbar-padding: var(--design-page-control-padding); +} +html:has(.design-proposal-theme) [data-canonical-toolbar] { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + column-gap: var(--collection-toolbar-gap); + row-gap: var(--design-page-toolbar-gap); + min-width: 0; +} +html:has(.design-proposal-theme) [data-canonical-toolbar] :is(.design-toolbar-controls, .design-toolbar-actions, [role="tablist"]:not([data-canonical-tabs])) { + display: flex; + flex-wrap: wrap; + gap: var(--collection-toolbar-gap); +} +html:has(.design-proposal-theme) [data-canonical-toolbar] :is(button:not([data-toolbar-clear], .canonical-tab), [data-toolbar-control]) { + height: var(--collection-toolbar-height); + border: var(--task-row-rule-width) solid var(--collection-toolbar-border); + border-radius: var(--collection-toolbar-radius); + padding-inline: var(--collection-toolbar-padding); + gap: var(--design-page-control-gap); + font-size: var(--design-page-type-size); + line-height: var(--design-page-type-leading); + font-weight: var(--font-weight-normal); + background: var(--design-page-control-surface); + color: var(--muted-foreground); +} +html:has(.design-proposal-theme) [data-canonical-toolbar] button:is([data-size="icon"], [data-size="icon-sm"], .design-toolbar-icon) { + width: var(--collection-toolbar-height); + padding-inline: 0; +} +html:has(.design-proposal-theme) [data-canonical-toolbar] :is(button:not([data-toolbar-clear], .canonical-tab), [data-toolbar-control]):is(:hover, [aria-pressed="true"], [aria-selected="true"], [data-active="true"], [data-state="open"]) { + color: var(--foreground); + background: var(--muted-highlight); +} +html:has(.design-proposal-theme) [data-canonical-toolbar] button:is([data-toolbar-primary], .design-new-task) { + margin-inline-start: 0; + background: var(--foreground); + color: var(--background); + font-weight: var(--font-weight-semibold); +} +html:has(.design-proposal-theme) [data-canonical-toolbar] button:is([data-toolbar-primary], .design-new-task):hover { + background: color-mix(in oklab, var(--foreground) 90%, var(--task-list-surface)); + color: var(--background); +} +html:has(.design-proposal-theme) [data-canonical-toolbar] button > svg { + width: var(--design-page-icon-size); + height: var(--design-page-icon-size); + margin-inline: 0; +} +html:has(.design-proposal-theme) [data-canonical-toolbar] .design-view-toggle { + height: var(--collection-toolbar-height); + overflow: visible; +} +html:has(.design-proposal-theme) [data-canonical-toolbar] .design-view-toggle button { + height: calc(var(--collection-toolbar-height) - var(--task-row-rule-width) * 2); + border: 0; + border-radius: 0; +} +html:has(.design-proposal-theme) [data-canonical-toolbar] .design-view-toggle button:first-child { + border-start-start-radius: var(--collection-toolbar-radius); + border-end-start-radius: var(--collection-toolbar-radius); +} +html:has(.design-proposal-theme) [data-canonical-toolbar] .design-view-toggle button:last-child { + border-start-end-radius: var(--collection-toolbar-radius); + border-end-end-radius: var(--collection-toolbar-radius); +} +html:has(.design-proposal-theme) [data-canonical-toolbar] [data-toolbar-search] { + flex-shrink: 1; + max-width: 100%; +} +html:has(.design-proposal-theme) [data-canonical-toolbar] [data-toolbar-search] input { + background: transparent; +} + +/* Canonical pill tabs: Compact (12px) and Standard (14px). + Shared geometry applies to Radix content tabs and exclusive collection filters. */ +:root { + --canonical-tab-compact-height: calc(var(--spacing) * 6); + --canonical-tab-standard-height: calc(var(--spacing) * 8); + --canonical-tab-padding: calc(var(--spacing) * 2.5); + --canonical-tab-radius: var(--radius-md); + --canonical-tab-compact-gap: calc(var(--spacing) * 1.5); + --canonical-tab-standard-gap: calc(var(--spacing) * 2); + --canonical-tab-disabled-opacity: 0.5; + --canonical-tab-standard-leading: calc(var(--spacing) * 4.5); +} +[data-canonical-tabs].canonical-tab-list { + display: flex; + flex-wrap: wrap; + width: fit-content; + max-width: 100%; + height: auto; + justify-content: flex-start; + align-items: center; + padding: var(--spacing); + gap: var(--canonical-tab-standard-gap); + background: transparent; + color: var(--muted-foreground); +} +[data-canonical-tabs="compact"].canonical-tab-list { gap: var(--canonical-tab-compact-gap); } +[data-canonical-tabs] .canonical-tab { + position: relative; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + height: var(--canonical-tab-standard-height); + padding: 0 var(--canonical-tab-padding); + border: 0; + border-radius: var(--canonical-tab-radius); + background: transparent; + color: var(--muted-foreground); + font-family: var(--font-sans); + font-size: var(--text-sm); + line-height: var(--canonical-tab-standard-leading); + font-weight: var(--font-weight-normal); + white-space: nowrap; + box-shadow: none; + transition: background-color var(--default-transition-duration) var(--default-transition-timing-function), color var(--default-transition-duration) var(--default-transition-timing-function); +} +[data-canonical-tabs="compact"] .canonical-tab { + height: var(--canonical-tab-compact-height); + font-size: var(--text-xs); + line-height: calc(var(--text-xs) * var(--text-xs--line-height)); +} +[data-canonical-tabs] .canonical-tab::after { display: none; } +[data-canonical-tabs] .canonical-tab:is([data-state="active"], [aria-checked="true"]) { + background: var(--muted-highlight); + color: var(--foreground); + box-shadow: none; +} +[data-canonical-tabs] .canonical-tab:hover:not(:disabled) { + background: var(--muted-highlight); + color: var(--foreground); +} +[data-canonical-tabs] .canonical-tab:focus-visible { + outline: var(--focus-ring-stroke-width) solid var(--ring); + outline-offset: var(--focus-ring-stroke-width); + box-shadow: var(--focus-ring-shadow); +} +[data-canonical-tabs] .canonical-tab:disabled { opacity: var(--canonical-tab-disabled-opacity); pointer-events: none; } +html:has(.design-proposal-theme) [data-canonical-toolbar] .collection-toolbar-tabs select { + height: var(--collection-toolbar-height); + border-radius: var(--collection-toolbar-radius); + border-color: var(--collection-toolbar-border); +} diff --git a/ui/src/lib/attention.test.ts b/ui/src/lib/attention.test.ts index 3b7469466d3..9147f8b3a79 100644 --- a/ui/src/lib/attention.test.ts +++ b/ui/src/lib/attention.test.ts @@ -543,6 +543,21 @@ describe("filterAttentionItems", () => { expect(countActiveAttentionFilters(defaultAttentionFilterState)).toBe(0); }); + it("searches visible decision context case-insensitively and combines it with filters", () => { + const decision = buildItem({ + subject: { ...approval.subject, title: "Review connector recovery", identifier: "DES-3" }, + whyNow: "Verification complete", originAgentName: "Connector engineer", + project: approval.project, workspace: { id: "ws", name: "Sandbox" }, + detail: { kind: "generic", summaryExcerpt: "Reconnect safely", images: [] }, + }); + for (const query of [" REVIEW connector ", "des-3", "verification", "engineer", "ALPHA", "sandbox", "reconnect"]) { + expect(filterAttentionItems([decision, join], defaultAttentionFilterState, query)).toEqual([decision]); + } + expect(filterAttentionItems([decision], defaultAttentionFilterState, "review nonexistent")).toEqual([]); + expect(filterAttentionItems([decision], { ...defaultAttentionFilterState, severities: ["low"] }, "connector")).toEqual([]); + expect(filterAttentionItems(items, defaultAttentionFilterState, " ")).toBe(items); + }); + it("filters by source kind", () => { const result = filterAttentionItems(items, { ...defaultAttentionFilterState, sourceKinds: ["approval"] }); expect(result.map((i) => i.id)).toEqual(["ap"]); diff --git a/ui/src/lib/attention.ts b/ui/src/lib/attention.ts index 331fd7c7544..9caf77556e8 100644 --- a/ui/src/lib/attention.ts +++ b/ui/src/lib/attention.ts @@ -732,9 +732,17 @@ export function attentionItemMatchesFilters(item: AttentionItem, filters: Attent return true; } -export function filterAttentionItems(items: AttentionItem[], filters: AttentionFilterState): AttentionItem[] { - if (countActiveAttentionFilters(filters) === 0) return items; - return items.filter((item) => attentionItemMatchesFilters(item, filters)); +export function filterAttentionItems(items: AttentionItem[], filters: AttentionFilterState, query = ""): AttentionItem[] { + const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + if (countActiveAttentionFilters(filters) === 0 && terms.length === 0) return items; + return items.filter((item) => { + if (!attentionItemMatchesFilters(item, filters)) return false; + if (terms.length === 0) return true; + const text = [item.subject.title, item.subject.identifier, item.relatedIssue?.title, + item.relatedIssue?.identifier, item.whyNow, attentionDetailLine(item), + item.originAgentName, item.project?.name, item.workspace?.name].filter(Boolean).join(" ").toLocaleLowerCase(); + return terms.every(term => text.includes(term)); + }); } /** Distinct filterable dimensions present in the current feed, for the picker. */ diff --git a/ui/src/lib/task-list-views.test.ts b/ui/src/lib/task-list-views.test.ts new file mode 100644 index 00000000000..7807299a2c5 --- /dev/null +++ b/ui/src/lib/task-list-views.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { loadTaskListView, matchesTaskListView, saveTaskListView, taskListViewStorageKey } from "./task-list-views"; + +const task = { createdByUserId: null, assigneeUserId: null, status: "todo" } as const; + +describe("task list views", () => { + it("includes self-created OR self-assigned tasks in Inbox, without treating missing identities as a match", () => { + expect(matchesTaskListView("inbox", { ...task, createdByUserId: "me" }, false, "me")).toBe(true); + expect(matchesTaskListView("inbox", { ...task, createdByUserId: "other", assigneeUserId: "me" }, false, "me")).toBe(true); + expect(matchesTaskListView("inbox", { ...task, createdByUserId: "other", assigneeUserId: "other" }, true, "me")).toBe(false); + expect(matchesTaskListView("inbox", task, true, "me")).toBe(false); + expect(matchesTaskListView("inbox", task, true, null)).toBe(false); + }); + + it("includes blocked and in-review tasks and excludes other statuses", () => { + for (const status of ["blocked", "in_review", "todo", "backlog", "in_progress", "done", "cancelled"] as const) { + expect(matchesTaskListView("blocked", { ...task, status }, false, "me")).toBe(["blocked", "in_review"].includes(status)); + } + }); + + it("uses unread state independently of ownership and status; All tasks includes both", () => { + expect(matchesTaskListView("unread", task, true, "me")).toBe(true); + expect(matchesTaskListView("unread", task, false, "me")).toBe(false); + expect(matchesTaskListView("all", task, false, null)).toBe(true); + expect(matchesTaskListView("all", { ...task, status: "done" }, true, "me")).toBe(true); + }); + + it("defaults new users to All tasks and remembers each user's choice in its own scope", () => { + const values = new Map(); + const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); } }; + const first = taskListViewStorageKey("design-proposals", "first"); + const second = taskListViewStorageKey("design-proposals", "second"); + expect(loadTaskListView(first, storage)).toBe("all"); + saveTaskListView(first, "blocked", storage); + expect(loadTaskListView(first, storage)).toBe("blocked"); + expect(loadTaskListView(second, storage)).toBe("all"); + expect(loadTaskListView(taskListViewStorageKey("production", "first"), storage)).toBe("all"); + storage.setItem(first, "completed"); + expect(loadTaskListView(first, storage)).toBe("all"); + }); + + it("tolerates unavailable and denied storage", () => { + const denied = { getItem: () => { throw new Error("denied"); }, setItem: () => { throw new Error("denied"); } }; + expect(loadTaskListView("key", null)).toBe("all"); + expect(loadTaskListView("key", denied)).toBe("all"); + expect(() => saveTaskListView("key", "inbox", denied)).not.toThrow(); + }); +}); diff --git a/ui/src/lib/task-list-views.ts b/ui/src/lib/task-list-views.ts new file mode 100644 index 00000000000..18818e46d08 --- /dev/null +++ b/ui/src/lib/task-list-views.ts @@ -0,0 +1,51 @@ +import type { Issue } from "@paperclipai/shared"; + +export const taskListViews = [ + { value: "inbox", label: "Inbox" }, + { value: "unread", label: "Unread" }, + { value: "blocked", label: "Needs attention" }, + { value: "all", label: "All tasks" }, +] as const; + +export type TaskListView = typeof taskListViews[number]["value"]; +type ViewIssue = Pick; + +/** Apply within the current company's accessible tasks; ancestors are added separately. */ +export function matchesTaskListView(view: TaskListView, issue: ViewIssue, unread: boolean, userId: string | null): boolean { + switch (view) { + case "inbox": return Boolean(userId && (issue.createdByUserId === userId || issue.assigneeUserId === userId)); + case "unread": return unread; + case "blocked": return issue.status === "blocked" || issue.status === "in_review"; + case "all": return true; + } +} + +export function taskListViewStorageKey(scope: string, userId: string): string { + return `paperclip:task-list-view:v1:${encodeURIComponent(scope)}:${encodeURIComponent(userId)}`; +} + +type ViewStorage = Pick; +function browserStorage(): ViewStorage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + +export function loadTaskListView(key: string, storage: ViewStorage | null = browserStorage()): TaskListView { + try { + const value = storage?.getItem(key); + return taskListViews.find(option => option.value === value)?.value ?? "all"; + } catch { + return "all"; + } +} + +export function saveTaskListView(key: string, view: TaskListView, storage: ViewStorage | null = browserStorage()): void { + try { + storage?.setItem(key, view); + } catch { + // Storage denial must not prevent changing the current view. + } +} diff --git a/ui/src/lib/task-list.test.ts b/ui/src/lib/task-list.test.ts new file mode 100644 index 00000000000..ee2a99baf76 --- /dev/null +++ b/ui/src/lib/task-list.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { buildIssueTree } from "./issue-tree"; +import { sortTaskListItems, type TaskListSort } from "./task-list"; +import type { Issue } from "@paperclipai/shared"; + +describe("task-list sorting", () => { + it("sorts identifiers naturally and names case-insensitively without mutating the input", () => { + const items = ["PAP-10", "pap-2", "PAP-1"]; + expect(sortTaskListItems(items, { column: "taskId", direction: "ascending" }, item => item)).toEqual(["PAP-1", "pap-2", "PAP-10"]); + expect(items).toEqual(["PAP-10", "pap-2", "PAP-1"]); + expect(sortTaskListItems(["beta", "ALPHA", "alpha"], { column: "task", direction: "descending" }, item => item)).toEqual(["beta", "ALPHA", "alpha"]); + }); + + it.each(["ascending", "descending"])("puts missing values last for %s and retains ties", direction => { + const items = [{ id: "missing", value: null }, { id: "second", value: 2 }, { id: "empty", value: "" }, { id: "first", value: 1 }, { id: "tie", value: 2 }, { id: "invalid", value: NaN }]; + const sorted = sortTaskListItems(items, { column: "lastUpdated", direction }, item => item.value); + expect(sorted.map(item => item.id)).toEqual(direction === "ascending" + ? ["first", "second", "tie", "missing", "empty", "invalid"] + : ["second", "tie", "first", "missing", "empty", "invalid"]); + }); + + it("orders raw timestamps numerically rather than sorting relative date labels", () => { + const dates = [Date.parse("2026-10-14T17:00:00Z"), Date.parse("2026-10-13T17:00:00Z"), Date.parse("2026-10-14T18:00:00Z")]; + expect(sortTaskListItems(dates, { column: "lastUpdated", direction: "descending" }, date => date)).toEqual([dates[2], dates[0], dates[1]]); + }); + + it("preserves parent groups and sorts every sibling level through the existing tree builder", () => { + const items = [ + { id: "parent", parentId: null, issueNumber: 2 }, + { id: "child-a", parentId: "parent", issueNumber: 3 }, + { id: "grandchild", parentId: "child-a", issueNumber: 6 }, + { id: "child-b", parentId: "parent", issueNumber: 4 }, + { id: "root", parentId: null, issueNumber: 5 }, + ] as Issue[]; + const { roots, childMap } = buildIssueTree(sortTaskListItems(items, { column: "taskId", direction: "descending" }, item => item.issueNumber)); + expect(roots.map(item => item.id)).toEqual(["root", "parent"]); + expect(childMap.get("parent")?.map(item => item.id)).toEqual(["child-b", "child-a"]); + expect(childMap.get("child-a")?.map(item => item.id)).toEqual(["grandchild"]); + }); +}); diff --git a/ui/src/lib/task-list.ts b/ui/src/lib/task-list.ts new file mode 100644 index 00000000000..542378c2d2c --- /dev/null +++ b/ui/src/lib/task-list.ts @@ -0,0 +1,55 @@ +/** Ordered columns and token references shared by the proposed header and rows. */ +export const taskListColumns = [ + { id: "task", label: "Task", width: "var(--task-list-column-task)" }, + { id: "taskId", label: "Task ID", width: "var(--task-list-column-task-id)" }, + { id: "createdBy", label: "Created by", width: "var(--task-list-column-created-by)" }, + { id: "assignee", label: "Assignee", width: "var(--task-list-column-assignee)" }, + { id: "project", label: "Project", width: "var(--task-list-column-project)" }, + { id: "parentTask", label: "Parent Task", width: "var(--task-list-column-parent)" }, + { id: "tags", label: "Tags", width: "var(--task-list-column-tags)" }, + { id: "worktree", label: "Worktree", width: "var(--task-list-column-worktree)" }, + { id: "lastUpdated", label: "Last updated", width: "var(--task-list-column-updated)" }, +] as const; + +export type TaskListColumnId = (typeof taskListColumns)[number]["id"]; +export type TaskListSort = { column: TaskListColumnId; direction: "ascending" | "descending" }; +export type TaskListSortValue = string | number | null | undefined; +export const allTaskListColumns: readonly TaskListColumnId[] = taskListColumns.map(column => column.id); +export const defaultTaskListColumns: readonly TaskListColumnId[] = ["task", "taskId", "lastUpdated"]; +export const defaultTaskListSort: TaskListSort = { column: "taskId", direction: "ascending" }; + +/** Task occupies two adjacent physical tracks: status/disclosure, then title. */ +export function taskListColumnStart(columns: readonly TaskListColumnId[], id: TaskListColumnId): number { + const index = columns.indexOf(id); + return index + 1 + (columns.includes("task") ? (columns.indexOf("task") < index ? 1 : 0) : 1); +} + +export function reorderTaskListColumns(columns: readonly TaskListColumnId[], from: TaskListColumnId, to: TaskListColumnId): readonly TaskListColumnId[] { + const start = columns.indexOf(from); + const end = columns.indexOf(to); + if (start < 0 || end < 0 || start === end) return columns; + const next = [...columns]; + next.splice(end, 0, next.splice(start, 1)[0]); + return next; +} + +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +/** Sort raw values before building the tree, so only sibling order changes. + * Empty values stay last in either direction; equal values retain input order. + * Callers supply timestamps, task titles, names and sorted tag names, + * never formatted relative dates or rendered React nodes. + */ +export function sortTaskListItems(items: readonly T[], sort: TaskListSort, value: (item: T, column: TaskListColumnId) => TaskListSortValue): T[] { + return [...items].sort((a, b) => { + const left = value(a, sort.column); + const right = value(b, sort.column); + const leftMissing = left == null || left === "" || (typeof left === "number" && !Number.isFinite(left)); + const rightMissing = right == null || right === "" || (typeof right === "number" && !Number.isFinite(right)); + if (leftMissing || rightMissing) return Number(leftMissing) - Number(rightMissing); + const result = typeof left === "number" && typeof right === "number" + ? left - right + : collator.compare(String(left), String(right)); + return sort.direction === "ascending" ? result : -result; + }); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 430401639b9..239bc828292 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -18,6 +18,7 @@ import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext" import { PrimaryAgentProvider } from "./context/PrimaryAgentProvider"; import { ToastProvider } from "./context/ToastContext"; import { ThemeProvider } from "./context/ThemeContext"; +import { DesignSandboxProvider } from "./context/DesignSandboxContext"; import { TooltipProvider } from "@/components/ui/tooltip"; import { initPluginBridge } from "./plugins/bridge-init"; import { PluginLauncherProvider } from "./plugins/launchers"; @@ -87,7 +88,7 @@ getOrCreatePaperclipReactRoot(window, rootElement).render( - + diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 50368f56534..f659386cc12 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -51,6 +51,8 @@ import { AgentLifecycleStatus, useAgentLifecycleStatus } from "../components/Age import { MarkdownBody } from "../components/MarkdownBody"; import { CopyText } from "../components/CopyText"; import { IssueRow } from "../components/IssueRow"; +import { TaskSummaryList } from "../components/TaskSummaryList"; +import { useDesignSandbox } from "../context/DesignSandboxContext"; import { StatusGlyph } from "../components/StatusGlyph"; import { MembershipAction } from "../components/MembershipAction"; import { StarToggle } from "../components/StarToggle"; @@ -1735,6 +1737,7 @@ export function AgentOverview({ skillNames: string[]; agentRouteId: string; }) { + const designSandbox = useDesignSandbox(); const issuesById = useMemo(() => { const map = new Map(); for (const issue of assignedIssues) map.set(issue.id, issue); @@ -1857,7 +1860,7 @@ export function AgentOverview({

No recent tasks.

) : (
- {assignedIssues.slice(0, 6).map((issue) => ( + {designSandbox ? : assignedIssues.slice(0, 6).map((issue) => ( row.textContent?.includes(agentName)) ?? null; } +describe("agent search", () => { + it("combines case-insensitive name, title and role words with status and built-in filters", () => { + const alpha = makeAgent({ id: "alpha", name: "Alpha", role: "engineer", status: "active", title: "Connector engineer" }); + const beta = makeAgent({ id: "beta", name: "Beta", role: "designer", status: "paused", title: "Design reviewer" }); + const hidden = makeAgent({ id: "hidden", name: "Beta", status: "terminated" }); + const agents = [alpha, beta, hidden]; + expect(filterAgents(agents, "all", new Set(), " BETA design ")).toEqual([beta]); + expect(filterAgents(agents, "active", new Set(), "beta")).toEqual([]); + expect(filterAgents(agents, "paused", new Set(), "reviewer")).toEqual([beta]); + expect(filterAgents(agents, "builtin", new Set([alpha.id]), "connector")).toEqual([alpha]); + expect(filterAgents(agents, "all", new Set(), " ")).toEqual([alpha, beta]); + }); + + it("retains the hierarchy above a matching report and promotes reports of hidden agents", () => { + const child = { id: "child", name: "Designer", role: "designer", status: "paused", reports: [] }; + const root = { id: "root", name: "CEO", role: "ceo", status: "active", reports: [child] }; + expect(filterOrgTree([root], "all", new Set(), new Set([child.id]))).toEqual([root]); + expect(filterOrgTree([root], "all", new Set(), new Set())).toEqual([]); + expect(filterOrgTree([{ ...root, status: "terminated" }], "all", new Set(), new Set([child.id]))).toEqual([child]); + }); +}); function renderAgentsView(root: ReturnType | null, queryClient: QueryClient) { root!.render( @@ -369,6 +390,27 @@ describe("Agents", () => { vi.clearAllMocks(); }); + it("filters the rendered roster and clears search without changing its status route", async () => { + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ id: "alpha", name: "Alpha", role: "engineer" }), + makeAgent({ id: "beta", name: "Beta", role: "designer" }), + ]); + root = createRoot(container); + await act(async () => root!.render()); + await flushReact(); + await flushReact(); + const input = container.querySelector('input[aria-label="Search agents"]')!; + await act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "beta"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(findAgentRow(container, "Beta")).not.toBeNull(); + expect(findAgentRow(container, "Alpha")).toBeNull(); + await act(() => container.querySelector('button[aria-label="Clear search"]')!.click()); + expect(findAgentRow(container, "Alpha")).not.toBeNull(); + expect(mockRouterState.navigate).not.toHaveBeenCalled(); + }); + it.each([ ["streamlined", Agents], ["production", ProductionAgents], @@ -449,7 +491,8 @@ describe("Agents", () => { expect(orgViewport?.parentElement?.classList.contains("flex-1")).toBe(true); expect(orgViewport?.parentElement?.classList.contains("md:min-h-0")).toBe(true); expect(orgViewport?.parentElement?.classList.contains("h-(--sz-calc-38)")).toBe(false); - expect(orgViewport?.parentElement?.parentElement?.classList.contains("h-full")).toBe(true); + expect(orgViewport?.closest('[data-slot="tabs"]')?.classList.contains("h-full")).toBe(true); + expect(orgViewport?.closest('[role="tabpanel"]')?.classList.contains("flex-1")).toBe(true); expect(orgViewport?.parentElement?.parentElement?.classList.contains("min-h-0")).toBe(true); expect(container.querySelector('[aria-label="Zoom in"]')).not.toBeNull(); expect(container.querySelector('[aria-label="Zoom out"]')).not.toBeNull(); @@ -515,7 +558,6 @@ describe("Agents", () => { }); - it("shows effective environment and sandbox provider beside agents", async () => { mockAgentsApi.list.mockResolvedValue([ makeAgent({ @@ -842,7 +884,7 @@ describe("Agents", () => { await flushReact(); await flushReact(); - expect(container.textContent).toContain("1 agent"); + expect(container.textContent).not.toContain("1 agent"); expect(mockBuiltInAgentsApi.list).toHaveBeenCalledWith("company-1"); expect(container.textContent).toContain("Built-in"); expect(container.textContent).toContain("Briefs Agent"); diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index f1fa0e53754..1ef594f04ab 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -23,10 +23,10 @@ import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { OrgChart } from "./OrgChart"; import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils"; -import { PageTabBar } from "../components/PageTabBar"; -import { Tabs } from "@/components/ui/tabs"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; +import { AgentsToolbar } from "../components/AgentsToolbar"; import { Button } from "@/components/ui/button"; -import { AlertTriangle, Bot, Plus, List, Network } from "lucide-react"; +import { AlertTriangle, Bot } from "lucide-react"; import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared"; import { resourceMembershipState, @@ -93,10 +93,16 @@ function matchesFilter(status: string, tab: FilterTab): boolean { return true; } -function filterAgents(agents: Agent[], tab: FilterTab, builtInAgentIds: Set): Agent[] { +function matchesSearch(agent: Pick, query: string): boolean { + const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + const text = [agent.name, agent.role, roleLabels[agent.role], agent.title].filter(Boolean).join(" ").toLocaleLowerCase(); + return words.every(word => text.includes(word)); +} + +export function filterAgents(agents: Agent[], tab: FilterTab, builtInAgentIds: Set, query = ""): Agent[] { return agents .filter((a) => { - if (HIDDEN_AGENT_STATUSES.has(a.status)) return false; + if (HIDDEN_AGENT_STATUSES.has(a.status) || !matchesSearch(a, query)) return false; // The `builtin` filter keys on the built-in marker, not agent status. if (tab === "builtin") return builtInAgentIds.has(a.id); return matchesFilter(a.status, tab); @@ -168,10 +174,10 @@ function resolveAgentEnvironment( : describeMissingEnvironment(environmentId); } -function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set): OrgNode[] { +export function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set, matchingIds?: Set): OrgNode[] { return nodes .reduce((acc, node) => { - const filteredReports = filterOrgTree(node.reports, tab, builtInAgentIds); + const filteredReports = filterOrgTree(node.reports, tab, builtInAgentIds, matchingIds); // Hidden agents (terminated / pending_approval) never render as a row, but // any visible reports are promoted so the tree doesn't lose live agents. if (HIDDEN_AGENT_STATUSES.has(node.status)) { @@ -181,7 +187,7 @@ function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set 0) { + if ((nodeMatches && (!matchingIds || matchingIds.has(node.id))) || filteredReports.length > 0) { acc.push({ ...node, reports: filteredReports }); } return acc; @@ -192,6 +198,7 @@ function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set; } - const filtered = filterAgents(agents ?? [], tab, builtInAgentIds); - const filteredOrg = filterOrgTree(orgTree ?? [], tab, builtInAgentIds); + const filtered = filterAgents(agents ?? [], tab, builtInAgentIds, query); + const filteredOrg = filterOrgTree(orgTree ?? [], tab, builtInAgentIds, query.trim() ? new Set(filtered.map(agent => agent.id)) : undefined); const environmentDataLoading = environmentsEnabled && environments === undefined; const showEnvironmentColumn = environmentsEnabled && (environments === undefined || environments.length > 1); const resolveRenderedEnvironment = (agentId: string) => ( @@ -438,58 +445,17 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = }; return ( -
navigate(`/agents/${value}`)} className={cn( "@container", effectiveView === "org" ? "flex h-full min-h-0 flex-col gap-4" - : "space-y-4", + : "gap-4", )}> -
- navigate(`/agents/${v}`)}> - navigate(`/agents/${v}`)} - /> - -
- {!forceListView ?
- - -
: null} - -
-
- - {filtered.length > 0 && ( -

{filtered.length} agent{filtered.length !== 1 ? "s" : ""}

- )} + navigate(`/agents/${value}`)} view={effectiveView} onViewChange={setView} + hideViewToggle={forceListView} onNewAgent={openNewAgent} /> + {error &&

{error.message}

} {agents && agents.length === 0 && ( @@ -510,7 +476,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = {effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && (

- No agents match the selected status. + No agents match your search and selected status.

)} @@ -521,7 +487,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = {effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (

- No agents match the selected status. + No agents match your search and selected status.

)} @@ -542,7 +508,8 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = /> )} -
+ + ); } diff --git a/ui/src/pages/Artifacts.test.tsx b/ui/src/pages/Artifacts.test.tsx index b01d66ca32d..73f27e3a976 100644 --- a/ui/src/pages/Artifacts.test.tsx +++ b/ui/src/pages/Artifacts.test.tsx @@ -186,7 +186,7 @@ describe("Artifacts page", () => { cursor: undefined, }); const groupControl = container.querySelector('[data-testid="artifact-group-control"]') as HTMLButtonElement; - const allFilter = [...container.querySelectorAll('[role="tab"]')] + const allFilter = [...container.querySelectorAll('[aria-label="Filter artifacts by type"] button')] .find((element) => element.textContent === "All") as HTMLButtonElement; expect(groupControl).not.toBeNull(); expect(groupControl.textContent).toBe(""); diff --git a/ui/src/pages/Artifacts.tsx b/ui/src/pages/Artifacts.tsx index 4bb04e8cda2..c1dd952f02f 100644 --- a/ui/src/pages/Artifacts.tsx +++ b/ui/src/pages/Artifacts.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { ArrowLeft, Check, Layers, Package, Search, X } from "lucide-react"; +import { ArrowLeft, Layers, Package } from "lucide-react"; import type { To } from "react-router-dom"; import { artifactsApi, @@ -15,35 +15,12 @@ import { PageSkeleton } from "../components/PageSkeleton"; import { ArtifactCard } from "../components/artifacts/ArtifactCard"; import { ArtifactGroupCard } from "../components/artifacts/ArtifactGroupCard"; import { useSearchParams, Link } from "@/lib/router"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { cn } from "@/lib/utils"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; +import { ArtifactsToolbar, ARTIFACT_KIND_FILTERS } from "@/components/artifacts/ArtifactsToolbar"; +export { ARTIFACT_KIND_FILTERS, ARTIFACT_GROUP_OPTIONS, artifactGroupByLabel } from "@/components/artifacts/ArtifactsToolbar"; const ARTIFACTS_PAGE_SIZE = 30; const SEARCH_DEBOUNCE_MS = 250; -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" }, -]; - const KIND_VALUES = new Set(ARTIFACT_KIND_FILTERS.map((filter) => filter.value)); function parseGroupBy(value: string | null): ArtifactGroupBy { @@ -57,10 +34,6 @@ function parseKind(value: string | null): ArtifactKindFilter { : "all"; } -export function artifactGroupByLabel(value: ArtifactGroupBy): string { - return ARTIFACT_GROUP_OPTIONS.find((option) => option.value === value)?.label ?? "None"; -} - export function Artifacts() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -247,82 +220,7 @@ export function Artifacts() { return (
-
-
- - setDraftQuery(event.currentTarget.value)} - placeholder="Search artifacts..." - aria-label="Search artifacts" - className="h-9 pl-9 pr-9 text-sm" - /> - {draftQuery.length > 0 ? ( - - ) : null} -
- -
- - - - - - Group by - {ARTIFACT_GROUP_OPTIONS.map((option) => ( - selectGroupBy(option.value)} - className="justify-between" - > - {option.label} - {groupBy === option.value ? : null} - - ))} - - - -
- {ARTIFACT_KIND_FILTERS.map((filter) => ( - - ))} -
-
-
+ {viewingSelectedStack ? (
diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx index 3f52af4354a..6a88e8268a9 100644 --- a/ui/src/pages/CompanySkills.test.tsx +++ b/ui/src/pages/CompanySkills.test.tsx @@ -334,7 +334,7 @@ function buttonsNamed(node: ParentNode, name: string) { return Array.from(node.querySelectorAll("button")).filter((button) => button.textContent?.trim() === name); } -async function click(button: HTMLButtonElement) { +async function click(button: HTMLElement) { await act(async () => { button.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); @@ -373,6 +373,26 @@ describe("getSkillVersionDiffSelection", () => { }); describe("DiscoveryGrid IA presentation", () => { + it("uses the shared cards and toolbar for My Skills and preserves opening and creation actions", async () => { + const onOpenCard = vi.fn(); + const onCreate = vi.fn(); + const skill: CompanySkillListItem = { + ...makeDetail(makeVersion(1, "# My Review Skill"), { id: "my-skill", name: "My Review Skill" }), + catalogKind: null, originHash: null, packageName: null, packageVersion: null, + }; + const card = buildDiscoveryCards([skill], [])[0]!; + const node = await renderDiscoveryGrid({ collectionView: "authored", cards: [card], totalCount: 1, onOpenCard, onCreate, showBrowseRails: false }); + expect(node.querySelector('input[aria-label="Search my skills"]')).not.toBeNull(); + expect(node.querySelector('h1')?.textContent).toBe("My Skills"); + const skillButton = Array.from(node.querySelectorAll('[role="button"]')).find(button => button.textContent?.includes("My Review Skill"))!; + await click(skillButton); + expect(onOpenCard).toHaveBeenCalledWith(card); + const create = buttonsNamed(node, "Create new skill")[0]!; + await click(create); + expect(onCreate).toHaveBeenCalledOnce(); + expect(node.textContent).not.toContain("Recently visited"); + }); + it("makes the search scope explicit for Installed and Discover", async () => { let node = await renderDiscoveryGrid({ tab: "installed" }); expect(node.querySelector('input[aria-label="Search installed skills"]')).not.toBeNull(); diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index d9a5c8c4711..94ba466754d 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -1,3 +1,5 @@ +import { SkillsNavigationTabs } from "@/components/skill-studio/SkillsNavigationTabs"; +import { SkillsToolbar, type DiscoverySort } from "@/components/skill-studio/SkillsToolbar"; import { SkillBinaryFile } from "../components/SkillBinaryFile"; import { skillFileLinkHref } from "@/lib/skill-file-links"; import { SkillSourceProvenance } from "../components/SkillSourceProvenance"; @@ -590,18 +592,6 @@ export function skillDetailBreadcrumbs( ]; } -type DiscoverySort = "agents" | "stars" | "forks" | "recent" | "alphabetical"; - -const DISCOVERY_SORT_LABELS: Record = { - agents: "Most agents", - stars: "Most stars", - forks: "Most forks", - recent: "Recently updated", - alphabetical: "Alphabetical", -}; - -const DISCOVERY_SORTS: DiscoverySort[] = ["agents", "stars", "forks", "recent", "alphabetical"]; - export type DiscoveryCard = { key: string; skillId: string | null; @@ -1037,6 +1027,8 @@ function CategoryNav({ } export function DiscoveryGrid({ + collectionView, + navigation, tab, categories, categoryTotal, @@ -1083,6 +1075,8 @@ export function DiscoveryGrid({ folderNudgeStorageKey, showBrowseRails = true, }: { + collectionView?: "authored"; + navigation?: React.ReactNode; tab: DiscoveryTab; categories: DiscoveryCategory[]; categoryTotal: number; @@ -1135,11 +1129,14 @@ export function DiscoveryGrid({ showBrowseRails?: boolean; }) { const installedView = tab === "installed"; - const viewTitle = installedView ? "Installed skills" : "Discover skills"; - const viewDescription = installedView + const authoredView = collectionView === "authored"; + const viewTitle = authoredView ? "My Skills" : installedView ? "Installed skills" : "Discover skills"; + const viewDescription = authoredView + ? "Skills you can open, edit and test in Studio." + : installedView ? "Skills available to this organization." : "Browse skills from every available source."; - const searchLabel = installedView ? "Search installed skills" : "Search discoverable skills"; + const searchLabel = authoredView ? "Search my skills" : installedView ? "Search installed skills" : "Search discoverable skills"; // Source filter (github / skills.sh / local / …) lives in the grid so it // narrows whatever the parent already filtered by tab/category/search (PAP-10907 E). const [sourceBadgeFilter, setSourceBadgeFilter] = useState("all"); @@ -1234,125 +1231,21 @@ export function DiscoveryGrid({
{/* Search + sort + actions */} -
-
-

{viewTitle}

-

{viewDescription}

-
-
-
- - - - - - onSortChange(value as DiscoverySort)}> - {DISCOVERY_SORTS.map((option) => ( - - {DISCOVERY_SORT_LABELS[option]} - - ))} - - - - {availableSources.length > 1 ? ( - - - - - - - All sources - {availableSources.map((source) => ( - - {source.label} - - ))} - - - - ) : null} - - - - - - - - - Create new skill - - - - Discover skills - - Import from GitHub - Manage sources - - - Import from path or URL - - - - Import skills from project - - - - {showBrowseRails && folderResult && onFolderSelect ? ( + - undefined)} - /> + undefined)} />
- ) : null} - {onCreateFolder && !showFolderRail ? ( - - ) : null} - {onToggleSelectMode ? ( - - ) : null} -
+ ) : undefined} + /> {/* Mobile category selector (sidebar is hidden below md) */} {showBrowseRails && categories.length > 0 ? ( @@ -1432,7 +1325,7 @@ export function DiscoveryGrid({ message={ totalCount === 0 ? installedView - ? "No installed skills yet. Discover a skill or create one." + ? authoredView ? "No skills yet. Create a skill to start working in Studio." : "No installed skills yet. Discover a skill or create one." : "No skills are available to discover yet." : search || activeCategory || sourceFilterActive ? "No skills match your filters." @@ -4027,7 +3920,7 @@ function SkillPane({ ); } -export function CompanySkills() { +export function CompanySkills({ collectionView }: { collectionView?: "authored" } = {}) { const { "*": routePath } = useParams<{ "*": string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -4080,7 +3973,7 @@ export function CompanySkills() { error: string | null; }>({ open: false, catalogSkill: null, conflict: null, defaultSlug: null, defaultForce: false, defaultAction: "install", error: null }); const [discoverySearch, setDiscoverySearch] = useState(""); - const [discoverySort, setDiscoverySort] = useState("agents"); + const [discoverySort, setDiscoverySort] = useState(collectionView === "authored" ? "recent" : "agents"); const [createError, setCreateError] = useState(null); const [importDialogOpen, setImportDialogOpen] = useState(false); const [importFromProjectOpen, setImportFromProjectOpen] = useState(false); @@ -4114,7 +4007,7 @@ export function CompanySkills() { ) ? (tabParam as "all" | "installed" | "catalog" | "bundled") : "installed"; - const effectiveDiscoveryTab: DiscoveryTab = streamlinedUiEnabled + const effectiveDiscoveryTab: DiscoveryTab = collectionView === "authored" ? "installed" : streamlinedUiEnabled ? discoveryTab : legacyDiscoveryTab === "installed" ? "installed" @@ -4380,7 +4273,7 @@ export function CompanySkills() { useEffect(() => { setBreadcrumbs([ { label: "Skills", href: "/skills" }, - ...(isStudioNew + ...(collectionView === "authored" ? [{ label: "My Skills" }] : isStudioNew ? [{ label: studioForkFromId ? "Fork skill" : "New skill" }] : activeDetail ? skillDetailBreadcrumbs(activeDetail, skillFoldersQuery.data).slice(1) @@ -4388,7 +4281,7 @@ export function CompanySkills() { ? [{ label: "Detail" }] : []), ]); - }, [activeDetail, isStudioNew, routeSkillToken, setBreadcrumbs, skillFoldersQuery.data, studioForkFromId]); + }, [activeDetail, collectionView, isStudioNew, routeSkillToken, setBreadcrumbs, skillFoldersQuery.data, studioForkFromId]); const activeFile = fileQuery.data ?? displayedFile; function routeForSkill(skill: CompanySkillRouteSubject, path?: string | null) { @@ -4643,13 +4536,14 @@ export function CompanySkills() { ); const discoveryTabCards = useMemo( () => { + if (collectionView === "authored") return cardsForTab(discoveryCards, "installed"); if (streamlinedUiEnabled) return cardsForTab(discoveryCards, discoveryTab); if (legacyDiscoveryTab === "installed") return discoveryCards.filter((card) => card.installed); if (legacyDiscoveryTab === "catalog") return discoveryCards.filter((card) => card.catalogRef != null); if (legacyDiscoveryTab === "bundled") return discoveryCards.filter((card) => card.required); return discoveryCards; }, - [discoveryCards, discoveryTab, legacyDiscoveryTab, streamlinedUiEnabled], + [collectionView, discoveryCards, discoveryTab, legacyDiscoveryTab, streamlinedUiEnabled], ); const discoveryCategoryCounts = useMemo(() => { const counts = new Map(); @@ -5197,7 +5091,7 @@ export function CompanySkills() { // skills open the standalone catalog page (no modal, no legacy split view). function openDiscoveryCard(card: DiscoveryCard) { if (card.skillId) { - navigate(routeForSkillId(card.skillId)); + navigate(collectionView === "authored" ? skillStudioRoute(card.skillId) : routeForSkillId(card.skillId)); return; } if (card.catalogRef) { @@ -5497,6 +5391,8 @@ export function CompanySkills() {
) : null} : undefined} tab={effectiveDiscoveryTab} categories={discoveryCategoryCounts} categoryTotal={discoveryTabCards.length} @@ -5514,7 +5410,7 @@ export function CompanySkills() { onCreate={() => void openNewSkill()} onImport={() => setImportDialogOpen(true)} onImportFromProject={() => setImportFromProjectOpen(true)} - onBrowseDiscover={() => streamlinedUiEnabled ? setDiscoveryTab("discover") : setLegacyDiscoveryTab("catalog")} + onBrowseDiscover={() => collectionView === "authored" ? navigate("/skills?tab=discover") : streamlinedUiEnabled ? setDiscoveryTab("discover") : setLegacyDiscoveryTab("catalog")} onScan={(projectId) => scanProjects.mutate(projectId)} scanPending={scanProjects.isPending} scanStatus={scanStatusMessage} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 38bf9e51efb..cb55908b450 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -1,4 +1,6 @@ import { AgentIdentity } from "../components/AgentIdentity"; +import { TaskSummaryList } from "../components/TaskSummaryList"; +import { useDesignSandbox } from "../context/DesignSandboxContext"; import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "@/lib/router"; import { @@ -25,6 +27,7 @@ import { StatusIcon } from "../components/StatusIcon"; import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling"; import { ActivityRow } from "../components/ActivityRow"; +import { ActivityList } from "../components/ActivityList"; import { timeAgo } from "../lib/timeAgo"; import { cn, formatCents } from "../lib/utils"; import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags"; @@ -71,6 +74,7 @@ export function derivePausedAgentBanner(agents: Agent[] | undefined): PausedAgen } export function Dashboard() { + const designSandbox = useDesignSandbox(); const { selectedCompanyId, companies } = useCompany(); const { openOnboarding } = useDialogActions(); const location = useLocation(); @@ -397,8 +401,9 @@ export function Dashboard() {
) : null} -
+
- {recentActivity.map((event) => ( + {designSandbox ? animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined} /> : recentActivity.map((event) => ( No tasks yet.

) : ( - + designSandbox ? + + : {recentIssues.slice(0, 10).map((issue) => ( (null); const { dismiss, snooze } = useInboxDismissals(selectedCompanyId); + const [searchQuery, setSearchQuery] = useState(""); + // Toolbar preferences (persisted to localStorage, shared with the desk). const [groupBy, setGroupBy] = useState(() => loadAttentionGroupBy()); const [sortOrder, setSortOrder] = useState(() => loadAttentionSortOrder()); @@ -159,13 +161,13 @@ export function DecisionQueuePage() { // when something carries an explicit due decide-by); any explicit group-by keeps // the Inbox-style activity grouping. const groups = useMemo(() => { - const filtered = filterAttentionItems(listItems, filters); + const filtered = filterAttentionItems(listItems, filters, searchQuery); if (groupBy === "none") { return buildDeskShelves(filtered, now); } const sorted = sortAttentionItems(filtered, sortOrder); return groupAttentionItems(sorted, groupBy); - }, [listItems, filters, sortOrder, groupBy, now]); + }, [listItems, filters, searchQuery, sortOrder, groupBy, now]); const visibleCount = useMemo(() => groups.reduce((sum, group) => sum + group.items.length, 0), [groups]); @@ -222,12 +224,14 @@ export function DecisionQueuePage() { return (
-
+

{queue?.title ?? queueKey}

{queue?.description &&

{queue.description}

}
{visibleCount === 0 ? (
-

No decisions match your filters.

-

Adjust or clear the filters to see the rest.

+

No decisions match your search or filters.

+

Adjust or clear your search and filters to see the rest.

) : ( groups.map((group) => { diff --git a/ui/src/pages/DesignSandboxTasks.tsx b/ui/src/pages/DesignSandboxTasks.tsx new file mode 100644 index 00000000000..657a40b4629 --- /dev/null +++ b/ui/src/pages/DesignSandboxTasks.tsx @@ -0,0 +1,104 @@ +import { useEffect } from "react"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ListChecks, SearchX } from "lucide-react"; +import { accessApi } from "@/api/access"; +import { agentsApi } from "@/api/agents"; +import { useAccountIdentity } from "@/api/companies-query"; +import { issuesApi } from "@/api/issues"; +import { projectsApi } from "@/api/projects"; +import { EmptyState } from "@/components/EmptyState"; +import { KanbanBoard } from "@/components/KanbanBoard"; +import { TaskList, type TaskListItem } from "@/components/TaskList"; +import { TaskListToolbar } from "@/components/TaskListToolbar"; +import { TruncatedTextMarqueeProvider } from "@/components/TruncatedText"; +import { Button } from "@/components/ui/button"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { useCompany } from "@/context/CompanyContext"; +import { useDialogActions } from "@/context/DialogContext"; +import { useTaskListCollection } from "@/hooks/useTaskListCollection"; +import { buildCompanyUserLabelMap } from "@/lib/company-members"; +import { queryKeys } from "@/lib/queryKeys"; +import { taskListViewStorageKey } from "@/lib/task-list-views"; +import type { Issue } from "@paperclipai/shared"; + +const pageSize = 200; + +/** Real API adapter for the Design proposals / Pages / Tasks composition. + * All visual and collection behavior comes from the reviewed shared subjects. */ +export function DesignSandboxTasks() { + const { selectedCompanyId: companyId } = useCompany(); + const { userId } = useAccountIdentity(); + const { openNewIssue } = useDialogActions(); + const { setBreadcrumbs } = useBreadcrumbs(); + const client = useQueryClient(); + useEffect(() => { setBreadcrumbs([{ label: "Tasks" }]); }, [setBreadcrumbs]); + + const tasks = useInfiniteQuery({ + queryKey: [...queryKeys.issues.list(companyId!), "design-sandbox", userId], + queryFn: ({ pageParam, signal }) => issuesApi.list(companyId!, { + limit: pageSize, offset: pageParam, includeAgentCreatedTasks: true, + includeRoutineExecutions: true, sortField: "id", sortDir: "asc", + }, { signal }), + initialPageParam: 0, + getNextPageParam: (last, _pages, offset) => last.length === pageSize ? offset + pageSize : undefined, + enabled: !!companyId, + }); + const agents = useQuery({ queryKey: queryKeys.agents.list(companyId!), queryFn: () => agentsApi.list(companyId!), enabled: !!companyId }); + const projects = useQuery({ queryKey: queryKeys.projects.list(companyId!, { includeArchived: true }), queryFn: () => projectsApi.list(companyId!, { includeArchived: true }), enabled: !!companyId }); + const directory = useQuery({ queryKey: queryKeys.access.companyUserDirectory(companyId!), queryFn: () => accessApi.listUserDirectory(companyId!), enabled: !!companyId }); + const labels = useQuery({ queryKey: queryKeys.issues.labels(companyId!), queryFn: () => issuesApi.listLabels(companyId!), enabled: !!companyId }); + // The ordinary organization list does not include viewer read context. + // Ask the server for the signed-in user's unread set instead of guessing + // unread state from dates or reusing Storybook fixture flags. + const unread = useQuery({ + queryKey: [...queryKeys.issues.list(companyId!), "design-sandbox-unread", userId], + queryFn: async ({ signal }) => { + const ids = new Set(); + let page: Issue[]; + let offset = 0; + do { + page = await issuesApi.list(companyId!, { unreadForUserId: "me", includeAgentCreatedTasks: true, + includeRoutineExecutions: true, limit: pageSize, offset, sortField: "id", sortDir: "asc" }, { signal }); + for (const issue of page) ids.add(issue.id); + offset += pageSize; + } while (page.length === pageSize); + return ids; + }, + enabled: !!companyId && !!userId, + }); + const users = buildCompanyUserLabelMap(directory.data?.users); + const agentNames = new Map(agents.data?.map(agent => [agent.id, agent.name])); + const projectById = new Map(projects.data?.map(project => [project.id, project])); + const items: TaskListItem[] = [...new Map((tasks.data?.pages.flat() ?? []).map(issue => [issue.id, issue])).values()].map(issue => ({ + issue: { ...issue, project: issue.project ?? projectById.get(issue.projectId ?? "") }, + unread: unread.data?.has(issue.id) ?? false, + createdBy: issue.createdByAgentId ? agentNames.get(issue.createdByAgentId) ?? "Unknown agent" : users.get(issue.createdByUserId ?? "") ?? (issue.createdByUserId === "local-board" ? "Board" : "Unknown user"), + assignee: issue.assigneeAgentId ? agentNames.get(issue.assigneeAgentId) ?? "Unknown agent" : users.get(issue.assigneeUserId ?? "") ?? (issue.assigneeUserId === "local-board" ? "Board" : null), + })); + const collection = useTaskListCollection({ items, companyId, userId, + viewStorageKey: taskListViewStorageKey("design-integration-sandbox", userId ?? "local-board"), + }); + const update = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => issuesApi.update(id, data), + onSuccess: () => { void client.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) }); }, + }); + const error = tasks.error ?? agents.error ?? projects.error ?? directory.error ?? labels.error ?? unread.error ?? update.error; + const empty = collection.companyItems.length === 0 + ? openNewIssue()} /> + : ; + + if (!companyId) return ; + return
+ openNewIssue()} filters={{ state: collection.filters, onChange: collection.updateFilters, activeFilterCount: collection.activeFilterCount, + agents: agents.data ?? [], projects: projects.data ?? [], labels: labels.data ?? [], currentUserId: userId, creators: collection.creators }} /> + {error ?
{error instanceof Error ? error.message : "Could not load tasks"}
: null} + {tasks.isPending ?

Loading tasks…

: <> + {collection.narrowed ?

{collection.matchingItems.length} matching tasks{collection.layout === "list" && collection.visibleItems.length > collection.matchingItems.length ? " · Parent tasks included for context" : ""}

: null} + + {collection.layout === "board" ? collection.matchingItems.length ? item.issue)} agents={agents.data} onUpdateIssue={(id, data) => update.mutate({ id, data })} /> : empty : null} + {tasks.hasNextPage ?
Search, views and filters cover the {items.length} loaded tasks.
: null} + } +
; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 254588259ce..12f6e34acb1 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -22,7 +22,9 @@ import { EmailThreadProvider } from "../components/EmailMessageCard"; import { EmailTaskActivity } from "../components/EmailTaskActivity"; import { TaskChatScrollNavigation, taskChatScrollEntry } from "@/components/task-chat/scroll-navigation"; import { + lazy, memo, + Suspense, useCallback, useEffect, useLayoutEffect, @@ -187,6 +189,7 @@ import { liveBlueBadge } from "../lib/status-colors"; import { ApprovalCard } from "../components/ApprovalCard"; import { ProjectTile } from "../components/ProjectTile"; import { InlineEditor } from "../components/InlineEditor"; +import { useDesignSandbox } from "../context/DesignSandboxContext"; import { IssueChatThread, type IssueChatComposerHandle, @@ -1348,6 +1351,8 @@ type IssueDetailChatTabProps = { linkCaseReferences?: boolean; }; +const DesignSandboxTaskThread = lazy(() => import("../components/design-sandbox/DesignSandboxTaskThread")); + const IssueDetailChatTab = memo(function IssueDetailChatTab({ queryIssueId, browsers, @@ -1456,6 +1461,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ const ThreadComponent = classicTaskInterfaceEnabled ? IssueChatThread : TaskChatThread; + const designSandbox = useDesignSandbox(); const queryClient = useQueryClient(); const issueQueryRef = queryIssueId ?? issueId; const scrollLocation = useLocation(); @@ -2475,7 +2481,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
) : null; - return ( + const savedThread = (
); + + return import.meta.env.DEV && designSandbox && !classicTaskInterfaceEnabled && !conversationMode ? ( + + + {savedThread} + + + ) : savedThread; }); type IssueDetailActivityTabProps = { @@ -3075,6 +3096,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS streamlinedUiEnabled, loaded: taskInterfaceSettingsLoaded, } = useTaskDetailInterfaceMode(!!conversation); + const designSandbox = useDesignSandbox(); + const proposedTaskHeader = designSandbox && streamlinedTaskDetailEnabled; // Chat-style: the page wrapper spans the full center pane so the thread's // scroll viewport (and its scrollbar) reaches the properties-pane border; // every non-thread section re-centers itself at the 60rem shell cap instead. @@ -7184,7 +7207,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS const issueStatusControl = ( updateIssue.mutate({ status })} /> @@ -7200,7 +7223,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS > {streamlinedTaskDetailEnabled ? (
-
{issueStatusControl}
+ {!proposedTaskHeader ? ( +
{issueStatusControl}
+ ) : null}
updateIssue.mutateAsync({ title })} as="h2" - className="min-w-0 text-xl font-semibold leading-normal text-balance" + className={cn( + "min-w-0 text-balance", + proposedTaskHeader ? "text-2xl font-normal text-foreground" : "text-xl font-semibold", + "leading-normal", + )} /> - - {issue.identifier ?? issue.id.slice(0, 8)} - + {!proposedTaskHeader ? ( + + {issue.identifier ?? issue.id.slice(0, 8)} + + ) : null}
) : null} @@ -7224,13 +7255,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
- {streamlinedTaskDetailEnabled ? ( + {proposedTaskHeader ? ( +
+ {issueStatusControl} + + {issue.identifier ?? issue.id.slice(0, 8)} + +
+ ) : null} + {streamlinedTaskDetailEnabled && !proposedTaskHeader ? (
{issueStatusControl}
) : null} - {streamlinedTaskDetailEnabled ? ( + {streamlinedTaskDetailEnabled && !proposedTaskHeader ? ( {issue.identifier ?? issue.id.slice(0, 8)} @@ -7488,7 +7528,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS } }} > - + diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index ac11e1ba02f..9686be17c48 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,4 +1,6 @@ import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; +import { useDesignSandbox } from "../context/DesignSandboxContext"; +import { DesignSandboxTasks } from "./DesignSandboxTasks"; import { useEffect, useMemo, useCallback, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useLocation, useNavigate, useSearchParams } from "@/lib/router"; @@ -91,11 +93,12 @@ export function buildIssuesSearchUrl(currentHref: string, search: string): strin * get the same Views control in their toolbar, so the switch reads as one page. */ export function Issues() { + const designSandbox = useDesignSandbox(); const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled(); // The merged surface is Combined Inbox + Task List only. With the flag off, and always in // the legacy shell, Tasks is the plain task list and Inbox keeps its pages. - return streamlinedUiEnabled && combinedInboxTasksEnabled ? : ; + return designSandbox ? : streamlinedUiEnabled && combinedInboxTasksEnabled ? : ; } function StreamlinedTasks() { diff --git a/ui/src/pages/Projects.test.tsx b/ui/src/pages/Projects.test.tsx index 53a417f19ac..f45329db3a8 100644 --- a/ui/src/pages/Projects.test.tsx +++ b/ui/src/pages/Projects.test.tsx @@ -219,6 +219,21 @@ describe("Projects", () => { expect(content).toContain("in progress"); }); + it("searches projects and clears back to the grouped list", async () => { + await renderProjects(); + const input = container.querySelector('input[aria-label="Search projects"]')!; + await act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "charlie"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.textContent).toContain("Charlie"); + expect(container.textContent).not.toContain("Alpha"); + expect(container.textContent).not.toContain("Bravo"); + await act(() => container.querySelector('button[aria-label="Clear search"]')!.click()); + expect(container.textContent).toContain("Alpha"); + expect(container.textContent).toContain("Bravo"); + }); + it("sorts grouped projects by the selected field", async () => { await renderProjects(); await openSortMenu(); diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx index 796efa180dd..d9b2c22b52e 100644 --- a/ui/src/pages/Projects.tsx +++ b/ui/src/pages/Projects.tsx @@ -20,22 +20,11 @@ import { useResourceMembershipMutation, useResourceMemberships, } from "../hooks/useResourceMemberships"; -import { Button } from "@/components/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { ArrowUpDown, Check, Hexagon, Plus } from "lucide-react"; +import { ProjectsToolbar, type ProjectSortField, type ProjectSortDir } from "@/components/ProjectsToolbar"; +import { Hexagon } from "lucide-react"; import { Lock } from "lucide-react"; import { Card } from "@/components/ui/card"; -type ProjectSortField = "name" | "updated" | "created" | "targetDate"; -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" }, -]; - function compareProjectNames(left: Project, right: Project) { const nameDiff = left.name.localeCompare(right.name, undefined, { sensitivity: "base" }); return nameDiff !== 0 ? nameDiff : left.id.localeCompare(right.id); @@ -81,6 +70,7 @@ export function Projects() { const { selectedCompanyId } = useCompany(); const { openNewProject } = useDialogActions(); const { setBreadcrumbs } = useBreadcrumbs(); + const [query, setQuery] = useState(""); const [sortField, setSortField] = useState("name"); const [sortDir, setSortDir] = useState("asc"); @@ -100,8 +90,15 @@ export function Projects() { [allProjects], ); const sortedProjects = useMemo( - () => sortProjects(projects, sortField, sortDir), - [projects, sortDir, sortField], + () => { + const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + const matching = projects.filter(project => { + const text = `${project.name} ${project.description ?? ""}`.toLocaleLowerCase(); + return words.every(word => text.includes(word)); + }); + return sortProjects(matching, sortField, sortDir); + }, + [projects, query, sortDir, sortField], ); const groupedProjects = useMemo(() => { const groups = { @@ -117,7 +114,6 @@ export function Projects() { return groups; }, [membershipsQuery.data, sortedProjects]); - const sortLabel = PROJECT_SORT_OPTIONS.find((option) => option.field === sortField)?.label ?? "Name"; if (!selectedCompanyId) { return ; @@ -129,51 +125,8 @@ export function Projects() { return (
-
- - - - - -
- {PROJECT_SORT_OPTIONS.map((option) => ( - - ))} -
-
-
- -
+ { setSortField(field); setSortDir(direction); }} onNewProject={openNewProject} /> {error &&

{error.message}

} @@ -186,7 +139,9 @@ export function Projects() { /> )} - {projects.length > 0 && ( + {projects.length > 0 && sortedProjects.length === 0 &&

No projects match your search.

} + + {sortedProjects.length > 0 && (
{([ ["My Projects", groupedProjects.mine], diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index 199839b13ca..7364fb250e0 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -871,7 +871,6 @@ describe("Routines page", () => { } const text = container.textContent ?? ""; - expect(text).toContain("1 routine"); expect(text).toContain("Morning sync"); expect(text).not.toContain("Archived cleanup"); @@ -880,6 +879,57 @@ describe("Routines page", () => { }); }); + it("searches routine titles and descriptions and restores results when cleared", async () => { + routinesListMock.mockResolvedValue([ + createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }), + createRoutine({ id: "routine-2", title: "Weekly digest", description: "Review company activity", status: "active" }), + ]); + issuesListMock.mockResolvedValue([]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + + for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Morning sync"); attempts += 1) { + await act(async () => { + await flush(); + }); + } + + const input = container.querySelector('input[aria-label="Search routines"]')!; + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + async function search(value: string) { + await act(async () => { + setValue.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + await search("company review"); + expect(container.textContent).toContain("Weekly digest"); + expect(container.textContent).not.toContain("Morning sync"); + await search("missing"); + expect(container.textContent).toContain("No routines match your search."); + await search(""); + expect(container.textContent).toContain("Weekly digest"); + expect(container.textContent).toContain("Morning sync"); + + await act(async () => { + root.unmount(); + }); + }); + it("shows an outlined row-level run now button on the routines table", async () => { routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1", title: "Morning sync" })]); issuesListMock.mockResolvedValue([]); diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index 978a7e78ea6..8db47ee914b 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -2,7 +2,7 @@ import { AgentAvatar } from "@/components/AgentAvatar"; import { startTransition, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, Navigate, useNavigate, useSearchParams } from "@/lib/router"; -import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } from "lucide-react"; +import { ChevronDown, ChevronRight, Plus, Repeat } from "lucide-react"; import { routinesApi } from "../api/routines"; import { foldersApi } from "../api/folders"; import { agentsApi } from "../api/agents"; @@ -37,7 +37,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { RoutinesToolbar, type RoutineGroupBy, type RoutineSortField, type RoutineSortDir } from "@/components/RoutinesToolbar"; import { Select, SelectContent, @@ -86,10 +86,7 @@ function autoResizeTextarea(element: HTMLTextAreaElement | null) { element.style.height = `${element.scrollHeight}px`; } -type RoutineGroupBy = "folder" | "none" | "project" | "assignee"; type RoutinesTab = "routines" | "runs"; -type RoutineSortField = "updated" | "created" | "title" | "lastRun"; -type RoutineSortDir = "asc" | "desc"; type RoutineViewState = { sortField: RoutineSortField; @@ -361,6 +358,7 @@ export function Routines() { const routineViewStateKey = selectedCompanyId ? `paperclip:routines-view:${selectedCompanyId}` : "paperclip:routines-view"; + const [query, setQuery] = useState(""); const [routineViewState, setRoutineViewState] = useState(() => getRoutineViewState(routineViewStateKey)); const folderSelection = normalizeFolderSelection(searchParams.get("folder")); @@ -696,8 +694,15 @@ export function Routines() { }; }, [routineFolders, visibleRoutines]); const sortedRoutines = useMemo( - () => sortRoutines(folderFilteredRoutines, routineViewState.sortField, routineViewState.sortDir), - [folderFilteredRoutines, routineViewState.sortDir, routineViewState.sortField], + () => { + const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean); + const matches = folderFilteredRoutines.filter(routine => { + const text = `${routine.title} ${routine.description ?? ""}`.toLowerCase(); + return terms.every(term => text.includes(term)); + }); + return sortRoutines(matches, routineViewState.sortField, routineViewState.sortDir); + }, + [folderFilteredRoutines, query, routineViewState.sortDir, routineViewState.sortField], ); const routineSections = useMemo( () => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById, folderById), @@ -843,25 +848,16 @@ export function Routines() { return (
-
-
-

Routines

-

- Recurring work definitions that materialize into auditable execution tasks. -

-
-
- {streamlinedUiEnabled ? ( - - ) : null} - -
-
+ updateRoutineView({ sortField, sortDir })} + onGroupChange={groupBy => updateRoutineView({ groupBy, collapsedGroups: [] })} + onNewFolder={routineViewState.groupBy === "folder" && !hasRoutineFolders ? () => openCreateFolder() : undefined} + onCreateRoutine={openCreateRoutine} + runsHref={streamlinedUiEnabled ? auditSectionHref("runs", {}) : undefined} + selectMode={selectMode} onToggleSelect={showFolderRail ? () => setSelectMode(current => !current) : undefined} + /> {!streamlinedUiEnabled ? ( @@ -878,96 +874,6 @@ export function Routines() { ) : null}
-
-

- {visibleRoutines.length} routine{visibleRoutines.length === 1 ? "" : "s"} -

-
- - - - - -
- {([ - ["updated", "Updated"], - ["created", "Created"], - ["lastRun", "Last run"], - ["title", "Title"], - ] as const).map(([field, label]) => ( - - ))} -
-
-
- - - - - -
- {([ - ["folder", "Folder"], - ["project", "Project"], - ["assignee", "Agent"], - ["none", "None"], - ] as const).map(([value, label]) => ( - - ))} -
-
-
- {routineViewState.groupBy === "folder" && !hasRoutineFolders ? ( - - ) : null} - {showFolderRail ? ( - - ) : null} -
-
{routineViewState.groupBy === "folder" ? (
{folderSelection !== "all" ? (
diff --git a/ui/src/pages/SkillSources.test.tsx b/ui/src/pages/SkillSources.test.tsx index 7cd9b8a88d0..60e683c9e77 100644 --- a/ui/src/pages/SkillSources.test.tsx +++ b/ui/src/pages/SkillSources.test.tsx @@ -8,7 +8,7 @@ import { skillSourcesApi } from '@/api/skillSources'; import { ApiError } from '@/api/client'; import { sourceFixture } from '../../storybook/fixtures/githubSkillSources'; -const context = vi.hoisted(() => ({ navigate: vi.fn(), breadcrumbs: vi.fn(), sourceId: 'new' })); +const context = vi.hoisted(() => ({ navigate: vi.fn(), breadcrumbs: vi.fn(), sourceId: 'new', streamlinedEnabled: true })); vi.mock('@/context/CompanyContext', () => ({ useCompany: () => ({ selectedCompanyId: 'company-1', selectedCompany: { name: 'Acme' } }) })); vi.mock('@/context/BreadcrumbContext', () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: context.breadcrumbs }) })); vi.mock('@/lib/router', () => ({ @@ -17,6 +17,7 @@ vi.mock('@/lib/router', () => ({ useParams: () => ({ sourceId: context.sourceId }), })); vi.mock('@/api/skillSources', () => ({ skillSourcesApi: { list: vi.fn(), projectRepositories: vi.fn(), adoptProject: vi.fn(), settings: vi.fn(), branches: vi.fn(), repositories: vi.fn(), discoverStream: vi.fn(), create: vi.fn(), select: vi.fn(), refresh: vi.fn(), disconnect: vi.fn() } })); +vi.mock('@/hooks/useStreamlinedUiEnabled', () => ({ useStreamlinedUiEnabled: () => ({ enabled: context.streamlinedEnabled, loaded: true }) })); const repos = { connections: [{ id: 'personal', name: 'Personal' }, { id: 'shared', name: 'Engineering' }], @@ -55,6 +56,7 @@ beforeEach(() => { vi.mocked(skillSourcesApi.branches).mockResolvedValue({ defaultBranch: 'master', branches: [{ name: 'master', isDefault: true }], truncated: false }); vi.mocked(skillSourcesApi.discoverStream).mockResolvedValue(discovery); context.sourceId = 'new'; + context.streamlinedEnabled = true; sessionStorage.clear(); }); afterEach(async () => { @@ -63,6 +65,18 @@ afterEach(async () => { }); describe('GitHub skill source import', () => { + it('keeps the Skills views available while managing Sources', async () => { + context.sourceId = ''; + await mount(); + const navigation = document.querySelector('nav[aria-label="Skills views"]'); + expect([...navigation!.querySelectorAll('a')].map(link => [link.textContent, link.getAttribute('href')])).toEqual([ + ['Installed', '/skills'], + ['Discover', '/skills?tab=discover'], + ['My Skills', '/skills/studio'], + ]); + expect(navigation!.querySelector('[aria-current="page"]')).toBeNull(); + }); + it('groups the same repository and ref across projects and shows import progress until it is installed', async () => { context.sourceId = ''; const repository = { projectId: 'one', projectName: 'Paperclip', projectVisibility: 'open' as const, @@ -131,7 +145,7 @@ describe('GitHub skill source import', () => { expect(document.body.textContent).not.toContain('Other projects with this repo'); expect(document.body.textContent).not.toContain('Sentry'); expect(document.body.textContent).not.toContain('Paperclip'); - expect(document.querySelector('a[href="/skills"]')).toBeNull(); + expect(document.querySelector('section a[href="/skills"]')).toBeNull(); expect(document.querySelector('[role="status"]')?.textContent).toContain('Importing skills'); }); diff --git a/ui/src/pages/SkillSources.tsx b/ui/src/pages/SkillSources.tsx index 4e7d115f856..3f5f81b0a97 100644 --- a/ui/src/pages/SkillSources.tsx +++ b/ui/src/pages/SkillSources.tsx @@ -2,6 +2,7 @@ import { ProjectSkillRepositoryRow } from "@/components/skill-studio/ProjectSkil import { SkillSourceConnectionRepair } from "@/components/skill-studio/SkillSourceConnectionRepair"; import { SkillSourceBranchRepair } from "@/components/skill-studio/SkillSourceBranchRepair"; import { SkillSourceSyncControls } from "@/components/skill-studio/SkillSourceSyncControls"; +import { SkillsPageNavigation } from "@/components/skill-studio/SkillsNavigationTabs"; import { GithubIcon } from "@/components/icons/github-icon"; import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -97,6 +98,7 @@ export function SkillSources() {
+ {query.isPending &&

Loading sources…

} {query.error &&

{query.error.message}

} {query.data?.length === 0 && availableProjects.length === 0 &&

No repositories added yet. Import your skills to make them available in {selectedCompany?.name ?? 'this company'}.

} diff --git a/ui/src/pages/SkillStudio.tsx b/ui/src/pages/SkillStudio.tsx index 32a3f2ea06f..91fa0707ddf 100644 --- a/ui/src/pages/SkillStudio.tsx +++ b/ui/src/pages/SkillStudio.tsx @@ -1,3 +1,4 @@ +import { SkillsPageNavigation } from "@/components/skill-studio/SkillsNavigationTabs"; import { interactionReadinessRefetchInterval } from "@/lib/issue-thread-interactions"; import { SkillBinaryFile } from "../components/SkillBinaryFile"; import { SkillSourceProvenance } from "../components/SkillSourceProvenance"; @@ -411,6 +412,7 @@ function StudioCreateMode({ emptyLabel="New skill" /> +
+
+
{visited.length > 0 ? ( @@ -1055,6 +1059,7 @@ function StudioShell({ onSelectSkill={(nextSkillId) => navigate(skillStudioRoute(nextSkillId))} onOpenVersions={() => setVersionSheetOpen(true)} /> + {projectScan ? ( setForkDialogOpen(true)} /> ) : null} diff --git a/ui/src/pages/WhatNeedsMe.tsx b/ui/src/pages/WhatNeedsMe.tsx index 05ce04e3ac3..b4f50cbf49e 100644 --- a/ui/src/pages/WhatNeedsMe.tsx +++ b/ui/src/pages/WhatNeedsMe.tsx @@ -98,6 +98,8 @@ export function WhatNeedsMe() { // still follows a click, so keyboard actions target the row you just used. const [selectionFromKeyboard, setSelectionFromKeyboard] = useState(false); const [autoExpandDone, setAutoExpandDone] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + // Toolbar preferences (persisted to localStorage, Inbox pattern). const [groupBy, setGroupBy] = useState(() => loadAttentionGroupBy()); const [sortOrder, setSortOrder] = useState(() => loadAttentionSortOrder()); @@ -253,13 +255,13 @@ export function WhatNeedsMe() { // carries an explicit, due decide-by. Any explicit // group-by keeps the Inbox-style activity grouping. const groups = useMemo(() => { - const filtered = filterAttentionItems(deskItems, filters); + const filtered = filterAttentionItems(deskItems, filters, searchQuery); if (groupBy === "none") { return buildDeskShelves(filtered, now); } const sorted = sortAttentionItems(filtered, sortOrder); return groupAttentionItems(sorted, groupBy); - }, [deskItems, filters, sortOrder, groupBy, now]); + }, [deskItems, filters, searchQuery, sortOrder, groupBy, now]); const visibleCount = useMemo(() => groups.reduce((sum, group) => sum + group.items.length, 0), [groups]); const keyboardItems = useMemo( @@ -518,9 +520,11 @@ export function WhatNeedsMe() { return (
-
+

Decisions

- {filtered ? "No decisions match your filters." : "You're all caught up."} + {filtered ? "No decisions match your search or filters." : "You're all caught up."}

{filtered && ( -

Adjust or clear the filters to see the rest.

+

Adjust or clear your search and filters to see the rest.

)}
); diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 693fc8a9626..27770725a3a 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -414,16 +414,16 @@ describe("Connectors landing page", () => { expect(container.querySelector('[aria-label="App filters"]')?.textContent).toBe("PaperclipComposioArcadeInstalledAll"); expect(container.querySelector('[data-app-slug="remote-app"]')).toBeNull(); await search("Remote App"); - expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe("All"); + expect(container.querySelector('[aria-checked="true"]')?.textContent).toBe("All"); expect(container.querySelector('[data-app-slug="remote-app"]')).toBeTruthy(); await search(""); - expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe("Paperclip"); + expect(container.querySelector('[aria-checked="true"]')?.textContent).toBe("Paperclip"); await clickButton("Arcade", container); await search("Remote App"); - expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe("Arcade"); + expect(container.querySelector('[aria-checked="true"]')?.textContent).toBe("Arcade"); expect(container.textContent).toContain("No connectors match"); await clickButton("Clear search", container); - expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe("Arcade"); + expect(container.querySelector('[aria-checked="true"]')?.textContent).toBe("Arcade"); }); it.each(["composio", "arcade"] as const)("scopes %s to its catalog and accounts without native catalog overlaps", async (provider) => { @@ -445,7 +445,7 @@ describe("Connectors landing page", () => { expect(container.querySelector('[data-app-slug="other-app"]')).toBeNull(); await search("Notion"); - expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe(providerName); + expect(container.querySelector('[aria-checked="true"]')?.textContent).toBe(providerName); expect(container.textContent).toContain("No connectors match"); await clickButton("All", container); expect(container.querySelector('[data-app-slug="notion"]')?.textContent).toContain("Native Notion"); @@ -1068,7 +1068,7 @@ describe("Connectors landing page", () => { container.querySelector('header input[aria-label="Search connectors"]'), ).toBeTruthy(); expect(container.querySelector("header")?.classList).toContain( - "items-start", + "items-center", ); expect(container.querySelector("header")?.classList).not.toContain( "justify-end", diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index eb1096b1e85..9f6c214c0c5 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -1,5 +1,6 @@ import { AGGREGATOR_NAMES, aggregatorManagementUrl, isAppAggregator, type AggregatorAppSnapshot, type AggregatorAppsResponse } from "@paperclipai/shared/aggregator-apps"; -import { CatalogSourceFilters, type CatalogSource } from "./CatalogSourceFilters"; +import type { CatalogSource } from "./CatalogSourceFilters"; +import { ConnectorsToolbar } from "./ConnectorsToolbar"; import { ExecutorManagementSetup } from "./ExecutorManagementSetup"; import { ArcadeDiscoverySetup } from "./ArcadeDiscoverySetup"; import { AggregatorAppManager } from "./AggregatorAppManager"; @@ -25,7 +26,6 @@ import { Loader2, MoreHorizontal, PauseCircle, - Search, ServerCog, Trash2, } from "lucide-react"; @@ -79,7 +79,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { AGGREGATOR_APP_CATALOG, aggregatorAppIdentity, findComposioCatalogApp, type AggregatorAppCatalogEntry } from "@paperclipai/shared/aggregator-app-catalog"; import { AggregatorConnectDialog, aggregatorAppConnectHref } from "./AggregatorConnectDialog"; @@ -898,20 +897,8 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne return (
-
-
- - changeQuery(event.target.value)} - placeholder="Search connectors…" - aria-label="Search connectors" - className="pl-9" - /> -
- { setSource(value); setExpandedSearch(false); setPage(1); }} /> -
+ { setSource(value); setExpandedSearch(false); setPage(1); }} /> {loadFailed ? (
void }) { - return ; + return ({ value, label: CATALOG_SOURCE_NAMES[value] }))} value={source} onValueChange={onChange} />; } diff --git a/ui/src/pages/apps/ConnectorsToolbar.tsx b/ui/src/pages/apps/ConnectorsToolbar.tsx new file mode 100644 index 00000000000..a97ead147fc --- /dev/null +++ b/ui/src/pages/apps/ConnectorsToolbar.tsx @@ -0,0 +1,17 @@ +import { ToolbarSearch } from "@/components/ToolbarSearch"; +import { CatalogSourceFilters, type CatalogSource } from "./CatalogSourceFilters"; + +/** Shared catalog search and source filters; data and scope changes stay in Browse. */ +export function ConnectorsToolbar({ query, onQueryChange, source, onSourceChange }: { + query: string; + onQueryChange: (value: string) => void; + source: CatalogSource; + onSourceChange: (value: CatalogSource) => void; +}) { + return
+
+ +
+ +
; +} diff --git a/ui/storybook/.storybook/main.ts b/ui/storybook/.storybook/main.ts index 9fa6044d511..daaf0d960ea 100644 --- a/ui/storybook/.storybook/main.ts +++ b/ui/storybook/.storybook/main.ts @@ -5,6 +5,7 @@ import tailwindcss from "@tailwindcss/vite"; import { mergeConfig } from "vite"; import { storybookAgentAvatarAssets } from "../../../scripts/storybook-agent-avatar-assets.mjs"; import { generateStorybook } from "../../../scripts/storybook-catalog.mjs"; +import { storybookButtonUsage } from "../../../scripts/storybook-button-usage.mjs"; generateStorybook(); @@ -34,7 +35,7 @@ const config: StorybookConfig = { define: { "import.meta.env.VITE_PAPERCLIP_INSTANCE_URL": JSON.stringify(paperclipInstanceOrigin), }, - plugins: [tailwindcss(), storybookAgentAvatarAssets()], + plugins: [tailwindcss(), storybookAgentAvatarAssets(), storybookButtonUsage()], server: { proxy: { "/api/agent-avatars": { target: process.env.PAPERCLIP_STORYBOOK_API_URL ?? "http://localhost:3100", changeOrigin: true } } }, optimizeDeps: { include: ["motion/react", "react", "react-dom"] }, resolve: { diff --git a/ui/storybook/.storybook/preview-base.tsx b/ui/storybook/.storybook/preview-base.tsx index e59e6e82201..18c86580875 100644 --- a/ui/storybook/.storybook/preview-base.tsx +++ b/ui/storybook/.storybook/preview-base.tsx @@ -29,6 +29,7 @@ import { SidebarProvider } from "@/context/SidebarContext"; import { ThemeProvider } from "@/context/ThemeContext"; import { ToastProvider } from "@/context/ToastContext"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { AvatarInitialProvider } from "@/components/ui/avatar-initials"; import { storybookAgents, storybookApprovals, @@ -849,7 +850,7 @@ const preview: Preview = { ? Number.parseInt(width, 10) : undefined; return ( - + {context.parameters.paperclip?.home === "design-proposals" && context.parameters.designProposalTheme !== false ?
: }
); }, diff --git a/ui/storybook/stories/design-proposals/activity-list.stories.tsx b/ui/storybook/stories/design-proposals/activity-list.stories.tsx new file mode 100644 index 00000000000..5b5f9ac1ec4 --- /dev/null +++ b/ui/storybook/stories/design-proposals/activity-list.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useArgs } from "storybook/preview-api"; +import { ActivityListPreview, type ActivityListPreviewProps } from "../../support/design-proposals/ActivityListPreview"; + +const meta = { + id: "design-activity-list", + title: "Design proposals/Components/Activity list", + component: ActivityListPreview, + args: { density: "comfortable", containerWidth: "standard", withReferences: false }, + argTypes: { + density: { control: "inline-radio", options: ["comfortable", "compact"] }, + containerWidth: { control: "inline-radio", options: ["standard", "narrow", "smallest"] }, + withReferences: { control: "boolean" }, + onDensityChange: { control: false, table: { disable: true } }, + onWidthChange: { control: false, table: { disable: true } }, + }, + render: function Preview(args) { + const [, updateArgs] = useArgs(); + return updateArgs({ density })} onWidthChange={containerWidth => updateArgs({ containerWidth })} />; + }, + parameters: { + layout: "fullscreen", initialEntries: ["/PAP/dashboard"], + paperclip: { + capabilities: ["activity/overview", "activity/activity", "tasks/organize"], + status: "proposed", home: "design-proposals", implementation: "production", boundary: "fixtures", + exception: "Reusable activity composition staged for dashboard integration in the local design sandbox.", + notes: "Mounts ActivityList and the refreshed ActivityRow. Playground controls density and container width; mixed fixtures cover task, agent, project, approval, run and unlinked system events. Reuses task-row tokens, responsive metadata and CellMarquee hit testing. Task entries retain IssueLinkQuicklook, non-task events keep their entity destinations and never show a fabricated task ID. Fallback avatars use one initial through the proposal provider. Reference Changes retains added/removed issue references below the fixed-height row. Task preview data is seeded; fixture navigation does not establish production access checks or live backend acceptance. Sandbox toggle restores original dashboard rows and avatar policy.", + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Playground: Story = {}; +export const Narrow: Story = { args: { containerWidth: "narrow" } }; +export const Smallest: Story = { args: { containerWidth: "smallest" } }; +export const Compact: Story = { args: { density: "compact" } }; +export const ReferenceChanges: Story = { args: { withReferences: true } }; diff --git a/ui/storybook/stories/design-proposals/agent-workload-trial.stories.tsx b/ui/storybook/stories/design-proposals/agent-workload-trial.stories.tsx new file mode 100644 index 00000000000..89d910d4acf --- /dev/null +++ b/ui/storybook/stories/design-proposals/agent-workload-trial.stories.tsx @@ -0,0 +1,115 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { AgentWorkloadTrial } from "../../support/design-proposals/AgentWorkloadTrial"; +import { workloadAgentIds } from "../../support/design-proposals/agent-workload-trial-data"; + +const meta = { + id: "design-agent-workload-trial", + title: "Design proposals/AI Generated Test Pages/Agent workload trial", + component: AgentWorkloadTrial, + parameters: { + layout: "fullscreen", + initialEntries: ["/PAP/agents"], + docs: { description: { component: "Independent desktop workload composition using Design Proposals foundations, shell, sidebar, collection controls and narrow task summaries. Working means running; active tasks are in_progress and queued tasks are todo/backlog. Recent history excludes active and queued tasks to avoid duplication. Summary counts describe the full selected organization. Search matches every entered word across name, role and title and intersects the state filter. Selection opens a resizable contextual pane; Escape or Close dismisses it and restores card focus. Shared controls support maximize/restore. Six synthetic agents and 24 synthetic tasks include long names, busy/idle/paused states, an agent with no tasks and empty results. No agents are run, and no backend state changes are made." } }, + paperclip: { + capabilities: ["agents/organization", "agents/lifecycle"], + home: "design-proposals", status: "proposed", + exception: "Independent Agent workload reuse trial staged for desktop dark-mode review with synthetic workload data.", + implementation: "prototype", boundary: "fixtures", + notes: "Composes DesignPageShell/DesignPageAside, DesignSidebar, MetricCard, AgentAvatar, AgentStatusCapsule, Card/Button, CollectionToolbar, ToolbarSearch, TabFilters, SidePanelWindowControls, TaskSummaryList/TaskRowGroup and EmptyState. Local code owns card layout, search/state predicates, task aggregation, selection, dismissal and focus handoff. The route-backed AgentsToolbar requires an unused creation action, so this single collection uses canonical search and radio-filter components directly through CollectionToolbar slots. Trial-local CSS enables the shared toolbar row to wrap when its pane narrows independently of the viewport; all geometry references existing tokens. Task rows retain shared status, navigation, timeAgo, marquee and container-query behavior without copied row markup. DESIGN.md names an Agents toolbar specimen that the current toolbar stories do not expose. Fixtures simulate all data; task destinations, agent execution, authorization and backend persistence are outside this trial. Pane widths use isolated trial keys.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const SelectedAgent: Story = { args: { initialAgentId: workloadAgentIds.mira } }; +export const LongNames: Story = { args: { initialAgentId: workloadAgentIds.atlas } }; +export const Paused: Story = { args: { initialFilter: "paused", initialAgentId: workloadAgentIds.rowan } }; +export const NoTasks: Story = { args: { initialAgentId: workloadAgentIds.quinn } }; +export const NoResults: Story = { args: { initialQuery: "lunar botanist" } }; +export const EmptyOrganization: Story = { args: { emptyOrganization: true } }; + +export const Interactions: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const cards = () => within(canvas.getByRole("list", { name: "Agent cards" })).getAllByRole("button"); + const choose = (name: string) => canvas.getByRole("button", { name: `View workload for ${name}` }); + const state = (name: string) => canvas.getByRole("radio", { name }); + + await step("Search words match name and role; all state filters intersect search", async () => { + await expect(await canvas.findByRole("list", { name: "Agent cards" })).toBeVisible(); + await expect(cards()).toHaveLength(6); + await userEvent.type(canvas.getByRole("textbox", { name: "Search agents" }), "MIRA product"); + await expect(cards()).toHaveLength(1); + await expect(choose("Mira")).toBeVisible(); + await userEvent.click(state("Idle")); + await expect(canvas.getByText("No agents match this view")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Clear filters" })); + await expect(state("All")).toHaveAttribute("aria-checked", "true"); + await expect(canvas.getByRole("textbox", { name: "Search agents" })).toHaveFocus(); + for (const [label, count] of [["Working", 3], ["Idle", 2], ["Paused", 1]] as const) { + await userEvent.click(state(label)); + await expect(cards()).toHaveLength(count); + } + await userEvent.click(state("All")); + }); + await step("Keyboard filter navigation keeps a single selection", async () => { + state("All").focus(); + await userEvent.keyboard("{ArrowRight}"); + await expect(state("Working")).toHaveFocus(); + await userEvent.keyboard(" "); + await expect(state("Working")).toHaveAttribute("aria-checked", "true"); + await expect(cards()).toHaveLength(3); + await userEvent.click(state("Working")); + await expect(state("Working")).toHaveAttribute("aria-checked", "true"); + await userEvent.click(state("All")); + }); + await step("Keyboard selection opens active and recent tasks; Escape restores focus", async () => { + choose("Mira").focus(); + await userEvent.keyboard("{Enter}"); + await expect(canvas.getByRole("region", { name: "Agent tasks content" })).toHaveFocus(); + await expect(within(canvas.getByRole("list", { name: "Active tasks for Mira" })).getAllByRole("listitem")).toHaveLength(2); + await expect(within(canvas.getByRole("list", { name: "Recent tasks for Mira" })).getAllByRole("listitem")).toHaveLength(3); + await userEvent.keyboard("{Escape}"); + await expect(canvas.queryByRole("region", { name: "Agent tasks content" })).not.toBeInTheDocument(); + await expect(choose("Mira")).toHaveFocus(); + }); + await step("Pane resizing preserves shared narrow and wide row metadata", async () => { + await userEvent.click(choose("Mira")); + const handle = canvas.getByRole("separator", { name: "Resize right sidebar" }); + handle.focus(); + await userEvent.keyboard("{Home}"); + await waitFor(() => expect(Number(handle.getAttribute("aria-valuenow"))).toBe(Number(handle.getAttribute("aria-valuemin")))); + const row = canvas.getByRole("list", { name: "Active tasks for Mira" }).querySelector("li")!; + await expect(row.querySelector('[data-slot="task-row-identifier"]')).not.toBeVisible(); + await expect(row.querySelector('[data-slot="task-row-timestamp"]')).not.toBeVisible(); + const minimum = Number(handle.getAttribute("aria-valuemin")); + const increment = parseFloat(getComputedStyle(handle).getPropertyValue("--design-page-aside-resize-step")); + await userEvent.keyboard("{ArrowLeft}"); + await waitFor(() => expect(Number(handle.getAttribute("aria-valuenow"))).toBe(minimum + increment)); + await userEvent.keyboard("{End}"); + await waitFor(() => expect(Number(handle.getAttribute("aria-valuenow"))).toBe(Number(handle.getAttribute("aria-valuemax")))); + await userEvent.dblClick(handle); + await userEvent.click(canvas.getByRole("button", { name: "Maximize side panel" })); + await expect(row.querySelector('[data-slot="task-row-timestamp"]')).toBeVisible(); + await expect(row.querySelector('[data-slot="task-row-identifier"]')).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Restore side panel" })); + await userEvent.click(canvas.getByRole("button", { name: "Close side panel" })); + await expect(choose("Mira")).toHaveFocus(); + }); + await step("Taskless agent and unmatched search have recoverable empty states", async () => { + await userEvent.click(choose("Quinn")); + await expect(canvas.getByText("No tasks yet")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Back to agents" })); + await expect(choose("Quinn")).toHaveFocus(); + await userEvent.type(canvas.getByRole("textbox", { name: "Search agents" }), "lunar botanist"); + await expect(canvas.getByText("No agents match this view")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Clear filters" })); + await expect(cards()).toHaveLength(6); + await userEvent.click(choose("Mira")); + }); + }, +}; diff --git a/ui/storybook/stories/design-proposals/app-shell.stories.tsx b/ui/storybook/stories/design-proposals/app-shell.stories.tsx new file mode 100644 index 00000000000..94a0afdecea --- /dev/null +++ b/ui/storybook/stories/design-proposals/app-shell.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AppShellPreview } from "../../support/design-proposals/AppShellPreview"; + +const meta = { + id: "design-app-shell", + title: "Design proposals/Layouts/App shell", + component: AppShellPreview, + args: { rightSidebar: false, overflowingContent: false }, + argTypes: { + rightSidebar: { control: "boolean", description: "Include the optional right pane." }, + overflowingContent: { control: "boolean", description: "Add enough placeholder content to review independent pane scrolling." }, + }, + parameters: { + layout: "fullscreen", + initialEntries: ["/PAP/issues"], + docs: { description: { component: "Shared proposed desktop layout, also used by Pages/Tasks. Import DesignPageShell from @/components/DesignPageShell and supply sidebar, title and children. Add rightSidebar and rightSidebarTitle for three panes; rightSidebarHeader and rightSidebarControls accept the production tab strip and window controls. rightSidebarMaximized expands the panel across the workspace; headerActions can supply a reopen button. These stories use the task-detail page's SidePanelTabs, SidePanelLauncher, SidePanelWindowControls and useSidePanelTabs for adding, selecting, closing and reordering tabs. Closing the last tab reveals the launcher. Sidebar width uses the existing resize shell; pass sidebarStorageKey to isolate preferences. The right pane defaults to 320px, capped at 30% on narrower desktops. Drag its highlighted divider or use the arrow keys to resize; double-click animates back to default using the shared 200ms reset motion (instant with reduced motion). Widths persist separately from the left sidebar and survive maximize/restore. Resizing preserves space for the main pane. Main and right content scroll independently beneath fixed headers; scrollbars hide after scrolling stops using the shared idle-delay token. Colors come from the proposed dark foundation. Tab bodies and placeholder blocks are fixture content. Mobile navigation and panel overlay behavior are not defined by these desktop specimens." } }, + paperclip: { + capabilities: ["navigation-preferences/navigation"], + home: "design-proposals", + status: "proposed", + exception: "Shared desktop app-shell layouts staged with the design refresh before adoption in production routes.", + implementation: "production", + boundary: "fixtures", + notes: "Mounts the shared DesignPageShell, SidebarShell and DesignSidebar with neutral content slots. Two Panes is the same shell composition as Pages/Tasks. Three Panes adds production task-detail SidePanelTabs, SidePanelLauncher, SidePanelWindowControls and useSidePanelTabs through the shell's optional slots: add/select/close/reorder tabs, maximize/restore, close/reopen the pane, and recover from closing all tabs. Tab content is placeholder data, without task-detail API behavior. Independent Scrolling supplies overflowing content to both panes and uses the shared scrollbar-while-scrolling behavior. Both sidebars resize by dragging or keyboard, highlight their divider on hover/focus/drag, persist widths separately, and animate back to default on double-click. Reset timing matches the task-detail right pane; reduced motion resets instantly while dragging and keyboard resizing stay immediate. The right pane defaults to 320px capped at 30% on narrower desktops; custom widths retain space for the main pane and survive maximize/restore. Main and right surfaces use background, left uses muted, and dividers use border. Organization/account access, recent tasks and navigation destinations are fixtures. These dark desktop examples do not establish production routing, backend behavior, mobile layout or approved light-mode styling.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const TwoPanes: Story = {}; +export const ThreePanes: Story = { args: { rightSidebar: true } }; +export const IndependentScrolling: Story = { args: { rightSidebar: true, overflowingContent: true } }; diff --git a/ui/storybook/stories/design-proposals/buttons.stories.tsx b/ui/storybook/stories/design-proposals/buttons.stories.tsx new file mode 100644 index 00000000000..02734e7703f --- /dev/null +++ b/ui/storybook/stories/design-proposals/buttons.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useArgs } from "storybook/preview-api"; +import { ButtonsPlayground, type ButtonsPlaygroundProps } from "../../support/design-proposals/ButtonsPlayground"; +import { buttonSizes, buttonStates, buttonSurfaces, buttonVariants, defaultButtonSettings } from "../../support/design-proposals/ButtonSamples"; + +const meta = { + id: "design-buttons", + title: "Design proposals/Components/Buttons", + component: ButtonsPlayground, + args: { ...defaultButtonSettings }, + argTypes: { + label: { control: "text" }, + variant: { control: "select", options: Object.keys(buttonVariants) }, + size: { control: "select", options: Object.keys(buttonSizes) }, + icon: { control: "inline-radio", options: ["none", "leading", "trailing"] }, + state: { control: "select", options: Object.keys(buttonStates) }, + surface: { control: "select", options: Object.keys(buttonSurfaces) }, + onSettingsChange: { control: false }, + }, + parameters: { + layout: "fullscreen", + initialEntries: ["/PAP/issues"], + docs: { story: { inline: false }, description: { component: "Sandbox button styling workbench. Visible controls and Storybook args select label, style, measured size, icon placement, default/hover/keyboard-focus/pressed/disabled/loading state and semantic surface. All examples use the same proposed theme as the local design sandbox. Specimens report computed pixel dimensions, typography, padding, radius, icon size and colors. Forced states derive from compiled component CSS and remain preview-only. Contextual controls use real shared components with local fixture actions. No production comparison or repository-wide usage counts are shown." } }, + paperclip: { + capabilities: ["developer-labs/design"], + home: "design-proposals", + status: "proposed", + exception: "Button styling workbench for the proposed design system used by the local sandbox; not a production feature acceptance story.", + implementation: "mixed", + boundary: "fixtures", + notes: "Uses shared Button variants/sizes and sandbox task toolbar/list/sidebar, side-panel, agent action, conversation feedback, favorite, file display, switch, task status, attachment and relation-expansion controls. All samples inherit proposal foundations, including portaled menus. The workbench layout and controls are preview scaffolding. Mutation callbacks use local fixtures; no agents run or uploads persist. Legacy-only sidebar/filter/archive examples, feature-gated priority, setup-prompt catalog, isolated production iframe and repository-wide usage audit are excluded. Does not enumerate every page-specific override or establish backend acceptance.", + }, + }, + render: function Render(args) { + const [, updateArgs] = useArgs(); + return ; + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; diff --git a/ui/storybook/stories/design-proposals/colors.stories.tsx b/ui/storybook/stories/design-proposals/colors.stories.tsx new file mode 100644 index 00000000000..08d8f46e672 --- /dev/null +++ b/ui/storybook/stories/design-proposals/colors.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ColorsPreview } from "../../support/design-proposals/ColorsPreview"; + +const meta = { + id: "design-foundations-colors", + title: "Design proposals/Foundations/Colors", + component: ColorsPreview, + args: { section: "tokens" }, + argTypes: { section: { control: false } }, + parameters: { + layout: "fullscreen", + docs: { description: { component: "Light mode inherits the production palette; dark mode retains the Paper proposal palette. Use the theme toolbar to compare. The neutral palette comes from Paper’s Dark mode frame; the focus ring derives from dark-color-foreground. Token reference reads current CSS theme values; Surfaces & elevation uses shared Card and Input primitives; Text & interaction uses TaskList and its real Columns menu. The proposal decorator scopes the dark semantic theme, including portals, without changing production stories. This palette is staged for design review; task-status colors are outside this reference." } }, + paperclip: { + capabilities: [], + home: "design-proposals", + status: "proposed", + exception: "Shared color foundation: production light palette and proposed Paper dark palette. This does not establish production feature coverage.", + implementation: "production", + boundary: "fixtures", + notes: "Demonstrates the actual semantic CSS tokens and shared Card, Input, TaskList and dropdown components. Focus indicators use a foreground edge and a soft foreground halo at 20% opacity; their color and width tokens are shared. Pane resize controls retain their neutral gray highlight. Task data and menu preferences are local fixtures. Cards expose low, middle and high surface levels only in the proposal theme. Light mode reuses production semantic tokens; proposal-only surface names alias existing production colors. No status-color refresh, backend behavior or production adoption is implied.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const TokenReference: Story = { name: "Token reference" }; +export const SurfacesAndElevation: Story = { name: "Surfaces & elevation", args: { section: "surfaces" } }; +export const TextAndInteraction: Story = { name: "Text & interaction", args: { section: "interaction" } }; diff --git a/ui/storybook/stories/design-proposals/flyout.stories.tsx b/ui/storybook/stories/design-proposals/flyout.stories.tsx new file mode 100644 index 00000000000..efb1fbfc075 --- /dev/null +++ b/ui/storybook/stories/design-proposals/flyout.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FlyoutPreview } from "../../support/design-proposals/FlyoutPreview"; + +const meta = { + id: "design-flyout", + title: "Design proposals/Components/Popover", + component: FlyoutPreview, + args: { mode: "columns", shadow: "" }, + argTypes: { + mode: { control: "select", options: ["columns", "views", "account-menu"] }, + shadow: { name: "Shadow", control: "text", description: "Local CSS box-shadow override. Clear to inherit --shadow-flyout. Save the approved default in ui/src/index.css to apply it to every refreshed flyout." }, + }, + parameters: { + layout: "fullscreen", + docs: { description: { component: "Reusable Flyout, FlyoutContent, FlyoutHeader and FlyoutOptions composed from the existing Popover and Button primitives. Dropdown menus share the same sections and appearance while preserving menu-specific keyboard behavior. Muted-foreground labels and checkmarks become foreground on muted-highlight during hover or focus. The Shadow control is a local preview; --shadow-flyout in the shared token layer owns the saved default." } }, + paperclip: { + capabilities: ["tasks/organize"], + status: "proposed", + home: "design-proposals", + exception: "Shared flyout component and shadow tuning reference staged with the task-list design refresh.", + implementation: "production", + boundary: "fixtures", + notes: "Isolates the shared flyout composition used by the proposed page menus. Columns demonstrates toggle buttons and inherited-color checks; Views shares the page choices Inbox, Unread, Needs attention and All tasks, using existing radio-menu semantics with checkmarks; Account Menu mounts the canonical SidebarAccountMenu with the same DesignProposalAccess fixture boundary as Sidebar/Account Menu. Its profile link, Settings, Invite, Documentation, theme action, dismissal and focus behavior come directly from that component. Router and owner-access fixtures do not prove backend behavior or permission enforcement. Shadow controls affect this preview only; approved values belong in the shared --shadow-flyout token.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Columns: Story = {}; +export const Views: Story = { args: { mode: "views" } }; +// Keep the original story ID so existing review links remain valid. +export const Profile: Story = { name: "Account Menu", args: { mode: "account-menu" } }; diff --git a/ui/storybook/stories/design-proposals/project-health-trial.stories.tsx b/ui/storybook/stories/design-proposals/project-health-trial.stories.tsx new file mode 100644 index 00000000000..04fadad31d5 --- /dev/null +++ b/ui/storybook/stories/design-proposals/project-health-trial.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { ProjectHealthTrial } from "../../support/design-proposals/ProjectHealthTrial"; +import { projectHealthProjectIds } from "../../support/design-proposals/project-health-trial-data"; + +const meta = { + id: "design-project-health-trial", + title: "Design proposals/AI Generated Test Pages/Project health trial", + component: ProjectHealthTrial, + parameters: { + layout: "fullscreen", + initialEntries: ["/PAP/projects"], + docs: { description: { component: "Independent desktop reuse trial using only Design Proposals references. Shared shell, sidebar, metrics, cards, flyouts, inputs, task summaries and empty states; local project aggregation, name/description search and selection. Active means in progress. Needs attention uses the shared task-view predicate: blocked or in review. Summary totals always describe the selected organization, independent of filters. Selecting a card shows its six most recently updated tasks. Changing search, view or organization clears selection. Fixtures include long names, an empty project and an empty organization. No production route, API, permission or persistence coverage." } }, + paperclip: { + capabilities: ["projects/lifecycle", "projects/task-context"], + home: "design-proposals", + status: "proposed", + exception: "Independent Project health composition trial staged under Design Proposals; existing references and shared implementations are preserved.", + implementation: "prototype", + boundary: "fixtures", + notes: "Uses DesignPageShell, DesignSidebar, MetricCard, Card, Button, Badge, Input, shared flyout primitives, TaskSummaryList/TaskRowGroup and EmptyState. Project search/filter/selection and counts are local; attention reuses matchesTaskListView. TaskListToolbar/useTaskListCollection cannot express project views, so only their primitive composition is repeated. Project status uses neutral Badge text because no proposed project-health status component is defined. Sidebar menus, command palette and New Task mount existing fixture-backed components; destination pages and real project creation are outside this trial. Pane preferences use isolated trial keys. Desktop dark-mode scope only.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const SelectedProject: Story = { args: { initialProjectId: projectHealthProjectIds.onboarding } }; +export const LongProjectName: Story = { args: { initialProjectId: projectHealthProjectIds.international } }; +export const NeedsAttention: Story = { args: { initialView: "attention" } }; +export const EmptyProject: Story = { args: { initialProjectId: projectHealthProjectIds.partners } }; +export const NoResults: Story = { args: { initialQuery: "lunar greenhouse" } }; +export const EmptyOrganization: Story = { args: { emptyOrganization: true } }; + +export const Interactions: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const page = within(canvasElement.ownerDocument.body); + await step("Project selection shows only its recent tasks", async () => { + await userEvent.click(await canvas.findByRole("button", { name: "View recent tasks for Customer onboarding" })); + await expect(canvas.getByRole("list", { name: "Recent tasks for Customer onboarding" })).toBeVisible(); + await expect(canvas.getByRole("button", { name: "View recent tasks for Customer onboarding" })).toHaveAttribute("aria-pressed", "true"); + }); + await step("Search is case insensitive and clears stale selection", async () => { + await userEvent.type(canvas.getByRole("textbox", { name: "Search projects" }), "INTERNATIONAL"); + await expect(within(canvas.getByRole("list", { name: "Project cards" })).getAllByRole("button")).toHaveLength(1); + await expect(canvas.queryByRole("list", { name: "Recent tasks for Customer onboarding" })).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Clear search" })); + }); + await step("Attention filter uses blocked and review tasks and composes with search", async () => { + await userEvent.click(canvas.getByRole("button", { name: "Project view: All projects" })); + await userEvent.click(await page.findByRole("menuitemradio", { name: "Needs attention" })); + await expect(within(canvas.getByRole("list", { name: "Project cards" })).getAllByRole("button")).toHaveLength(2); + await userEvent.type(canvas.getByRole("textbox", { name: "Search projects" }), "Knowledge"); + await expect(canvas.getByText("No projects match this view")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Clear filters" })); + await expect(canvas.getByRole("button", { name: "Project view: All projects" })).toBeVisible(); + await expect(within(canvas.getByRole("list", { name: "Project cards" })).getAllByRole("button")).toHaveLength(6); + }); + await step("Empty projects offer a next step and closing restores focus", async () => { + const emptyProject = canvas.getByRole("button", { name: "View recent tasks for Partner integrations" }); + emptyProject.focus(); + await userEvent.keyboard("{Enter}"); + await expect(canvas.getByText("No tasks yet")).toBeVisible(); + await userEvent.click(within(canvas.getByRole("region", { name: "Recent tasks content" })).getByRole("button", { name: "Browse projects" })); + await expect(emptyProject).toHaveFocus(); + await expect(canvas.getByText("Select a project")).toBeVisible(); + }); + await step("An unmatched search recovers to all projects", async () => { + await userEvent.type(canvas.getByRole("textbox", { name: "Search projects" }), "lunar greenhouse"); + await expect(canvas.getByText("No projects match this view")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Clear filters" })); + await expect(canvas.getByRole("textbox", { name: "Search projects" })).toHaveFocus(); + await userEvent.click(canvas.getByRole("button", { name: "View recent tasks for Customer onboarding" })); + }); + }, +}; diff --git a/ui/storybook/stories/design-proposals/segmented-controllers.stories.tsx b/ui/storybook/stories/design-proposals/segmented-controllers.stories.tsx new file mode 100644 index 00000000000..cf12e08c414 --- /dev/null +++ b/ui/storybook/stories/design-proposals/segmented-controllers.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useArgs } from "storybook/preview-api"; +import { SegmentedControllersPreview, type SegmentedControllersPreviewProps } from "../../support/design-proposals/SegmentedControllersPreview"; + +const meta = { + id: "design-segmented-controllers", + title: "Design proposals/Components/Segmented controllers", + component: SegmentedControllersPreview, + args: { width: "full", disableAgentActions: false }, + argTypes: { + width: { control: "inline-radio", options: ["full", "narrow"] }, + disableAgentActions: { control: "boolean" }, + onWidthChange: { control: false }, + }, + parameters: { + layout: "fullscreen", + paperclip: { + capabilities: [], home: "design-proposals", status: "proposed", + exception: "Cross-cutting comparison of segmented controller patterns from proposal and production surfaces.", + implementation: "mixed", boundary: "fixtures", + notes: "Mounts FilePreviewModeToggle directly and isolates existing TabsList/Button/native-button compositions from AuditFeed, TaskListToolbar, IssuesList, Agents, AgentDetail, Secrets, GoalDetail and IssueProperties. Local selection, disabled audit choice and narrow width are inspectable. No queries or mutations. Inline compositions are not page mounts; model lane retains existing incomplete radio keyboard behavior. Existing application controls are unchanged.", + }, + }, + render: function Render(args) { + const [, updateArgs] = useArgs(); + return updateArgs({ width })} />; + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Playground: Story = {}; +export const Disabled: Story = { args: { disableAgentActions: true } }; +export const Narrow: Story = { args: { width: "narrow" } }; diff --git a/ui/storybook/stories/design-proposals/sidebar.stories.tsx b/ui/storybook/stories/design-proposals/sidebar.stories.tsx new file mode 100644 index 00000000000..6d5a07e45d9 --- /dev/null +++ b/ui/storybook/stories/design-proposals/sidebar.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { userEvent, within } from "storybook/test"; +import { SidebarPreview } from "../../support/design-proposals/SidebarPreview"; + +const meta = { + id: "design-sidebar", + title: "Design proposals/Components/Sidebar", + component: SidebarPreview, + args: { scenario: "default" }, + argTypes: { scenario: { control: false } }, + parameters: { + layout: "fullscreen", + initialEntries: ["/PAP/issues"], + docs: { description: { component: "Shared proposed DesignSidebar, also mounted by Pages/Tasks. Uses the production organization/account menus, navigation primitives, resize shell, task composer and command palette. Review resizing, selected/hover/focus states, truncated label motion, scrolling recents and menus here; check the Tasks page for the full composition. Story widths persist separately from the page. Fixture navigation and owner access do not establish production routes or permissions." } }, + paperclip: { + capabilities: ["tasks/organize", "inbox-search/search"], + status: "proposed", + home: "design-proposals", + exception: "Shared navigation component staged for refinement with the proposed task-list page.", + implementation: "production", + boundary: "fixtures", + notes: "Renders the same DesignSidebar as Pages/Tasks inside DesignPageShell and SidebarShell. Default supports hover, focus, resizing and dialogs; Narrow uses the real resize handle's Home key. Long Labels, Many Recent Tasks and Empty Recent Tasks vary supplied fixtures without replacing navigation markup. Organization Menu and Account Menu open existing production menus with shared owner-access fixtures and proposed Popover appearance. Recents remain company scoped and scroll independently beneath their fixed heading; the shared scrollbar is visible only during scrolling and its short token-defined idle interval. Story-specific width storage isolates review from the page. Company data, recent tasks, router destinations and owner identity are simulated; production backend, permission enforcement and mobile composition are outside this review.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const Narrow: Story = { + args: { scenario: "narrow" }, + play: async ({ canvasElement }) => { + const handle = await within(canvasElement).findByRole("separator", { name: "Resize sidebar" }); + handle.focus(); + await userEvent.keyboard("{Home}"); + }, +}; +export const LongLabels: Story = { args: { scenario: "long-labels" } }; +export const ManyRecentTasks: Story = { args: { scenario: "many-recents" } }; +export const EmptyRecentTasks: Story = { args: { scenario: "empty-recents" } }; +export const OrganizationMenu: Story = { + args: { scenario: "organization-menu" }, + play: async ({ canvasElement }) => { + await userEvent.click(await within(canvasElement).findByRole("button", { name: /organization switcher/ })); + }, +}; +export const AccountMenu: Story = { + args: { scenario: "account-menu" }, + play: async ({ canvasElement }) => { + await userEvent.click(await within(canvasElement).findByRole("button", { name: "Open account menu" })); + }, +}; diff --git a/ui/storybook/stories/design-proposals/tabs.stories.tsx b/ui/storybook/stories/design-proposals/tabs.stories.tsx new file mode 100644 index 00000000000..286173a2a94 --- /dev/null +++ b/ui/storybook/stories/design-proposals/tabs.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useArgs } from "storybook/preview-api"; +import { TabsPreview, type TabsPreviewProps } from "../../support/design-proposals/TabsPreview"; + +const meta = { + id: "design-tabs", + title: "Design proposals/Components/Tabs", + component: TabsPreview, + args: { pattern: "all", width: "full" }, + argTypes: { + pattern: { control: "select", options: ["all", "standard", "compact"] }, + width: { control: "inline-radio", options: ["full", "narrow"] }, + onWidthChange: { control: false }, + }, + parameters: { + layout: "fullscreen", + docs: { description: { component: "Two canonical filled tab sizes: Compact (12px) and Standard (14px), based on the supplied artifact selector and Paper tab bar. TabBar retains Radix content-panel semantics; TabFilters provides exclusive collection choices with roving focus. Agents, Audit, Connectors and Artifacts compose the same shared types." } }, + paperclip: { + capabilities: [], home: "design-proposals", status: "proposed", + exception: "Cross-cutting tab and collection-filter comparison staged for design consolidation rather than acceptance coverage of any whole feature.", + implementation: "mixed", boundary: "fixtures", + notes: "Mounts canonical TabBar and TabFilters with local panels/filter state. Compact and Standard share filled selection, hover and focus styling. PageTabBar adopts Standard; CatalogSourceFilters adopts Standard radio choices; ArtifactKindFilters supports both sizes; ArtifactsToolbar explicitly uses Standard. Only Playground, Standard and Compact are exposed in this grouping. Narrow wrapping and a disabled choice are inspectable. Full page routing, authorization and queries are outside this specimen.", + }, + }, + render: function Render(args) { + const [, updateArgs] = useArgs(); + return updateArgs({ width })} />; + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Playground: Story = {}; +export const Standard: Story = { args: { pattern: "standard" } }; +export const Compact: Story = { args: { pattern: "compact" } }; diff --git a/ui/storybook/stories/design-proposals/task-hover-card.stories.tsx b/ui/storybook/stories/design-proposals/task-hover-card.stories.tsx new file mode 100644 index 00000000000..21be6696253 --- /dev/null +++ b/ui/storybook/stories/design-proposals/task-hover-card.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useArgs } from "storybook/preview-api"; +import { TaskHoverCardPreview } from "../../support/design-proposals/TaskHoverCardPreview"; + +const meta = { + id: "design-task-hover-card", + title: "Design proposals/Components/Task hover card", + component: TaskHoverCardPreview, + args: { enabled: true, showDescription: true }, + argTypes: { + enabled: { control: "boolean", description: "Enable the text link's existing optional hover preview. Activity keeps its preview; task summaries keep it off." }, + showDescription: { control: "boolean" }, + onEnabledChange: { control: false, table: { disable: true } }, + }, + render: function Preview(args) { + const [, updateArgs] = useArgs(); + return updateArgs({ enabled })} />; + }, + parameters: { + layout: "fullscreen", + initialEntries: ["/PAP/issues"], + paperclip: { + capabilities: ["tasks/organize", "activity/overview"], + status: "proposed", home: "design-proposals", implementation: "production", boundary: "fixtures", + exception: "Proposed task preview appearance staged alongside the task list and shared popover tokens.", + notes: "Mounts the shared IssueQuicklookCard, real task Link/IssueLinkQuicklook, ActivityRow and TaskSummaryList. The static specimen exposes styling; interactive examples retain production hover-intent, focus, dismissal and link behavior. The text-link control maps to disableIssueQuicklook; recent-task summaries remain opted out. The refreshed card follows the Paper reference: 280px width, 16px padding, status/ID/time metadata, 14px semibold two-line title and 12px three-line description; it omits project metadata and shares --shadow-flyout. Task details are seeded into the query cache; fixtures do not prove server access checks, navigation acceptance or live fetching.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Playground: Story = {}; +export const NoDescription: Story = { args: { showDescription: false } }; +export const PreviewOff: Story = { args: { enabled: false } }; diff --git a/ui/storybook/stories/design-proposals/task-list-page.stories.tsx b/ui/storybook/stories/design-proposals/task-list-page.stories.tsx new file mode 100644 index 00000000000..a31eccd3a75 --- /dev/null +++ b/ui/storybook/stories/design-proposals/task-list-page.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TaskListPagePreview } from "../../support/design-proposals/TaskListPagePreview"; + +const meta = { + id: "tasks-list-page-design", + title: "Design proposals/Pages/Tasks", + component: TaskListPagePreview, + parameters: { + layout: "fullscreen", + initialEntries: ["/PAP/issues"], + docs: { description: { component: "Full-page design proposal composed from the reviewed task rows and columns, production sidebar organization/account menus, task filters, Kanban board and task composer. Default opens with Task, Task ID and Last updated; headers remain inactive until the user sorts. Search, filters, views, expand/collapse, sorting and column configuration run against local fixture state. Sidebar labels use sm and icons match Paper. Organization selection uses the production switcher with company-scoped task fixtures. Views are Inbox, Unread, Needs attention and All tasks. New users start on All tasks; the last view persists per user in isolated proposal browser storage. Navigation, task creation, cross-device preferences and backend behavior require app integration." } }, + paperclip: { + capabilities: ["tasks/organize", "inbox-search/search"], + status: "proposed", + home: "design-proposals", + exception: "Full-page design refresh staged for visual approval before agent reproduction and production adoption.", + implementation: "production", + boundary: "fixtures", + notes: "Composes DesignPageShell, DesignSidebar, TaskListToolbar and TaskList from shared components, with useTaskListCollection supplying the shared search, view, creator-filter and expansion behavior. DesignSidebar is the same component reviewed separately under Components/Sidebar. SidebarCompanyMenu and SidebarAccountMenu preserve production behavior with the shared proposed flyout appearance. The organization, account, Views and Columns menus share background, highlight, text and shadow tokens. The account menu fits its contents and shows Invite using owner-access and loaded-policy fixtures; production invitation gates remain intact. The sidebar uses muted and border; the task search field uses background-alt. The organization control omits its caret and has a separate global-search button that opens the production CommandPalette. Truncated sidebar and menu labels opt into the same CellMarquee motion and fade as table cells. Navigation labels use sm and icons match Paper. The grouping icon is omitted. Headers have no active sort until the user chooses one. Organization selection scopes the fixture tasks and recent links. Search and filters keep ancestors for context. The filter popover follows the Paper five-group layout: Status, Project, Creator, Assignee and Label, with per-group searches, checkboxes and the shared popover shadow. Its refreshed presentation is opt-in; legacy filter layouts remain unchanged. Views appear as Inbox, Unread, Needs attention and All tasks. Inbox matches tasks created by or assigned to the current user; Needs attention matches blocked and in-review statuses. The last view persists per user in isolated proposal browser storage across reloads and organizations, defaulting to All tasks for new users. List/board and expansion use local fixture state. The proposed board has one horizontal scroll viewport with the table's shared clipping indicators and fade tokens; fades update on scroll and resize. Board and New Task reuse existing components; their appearance is not a new approved design. Sidebar links are router fixtures, not destination-page coverage. No production route, API or production preferences change. Visual review is required before this becomes the reference for a fresh-agent reproduction test.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const AllColumns: Story = { args: { allColumns: true } }; +export const NoResults: Story = { args: { initialQuery: "unmatched search" } }; diff --git a/ui/storybook/stories/design-proposals/task-row-design.stories.tsx b/ui/storybook/stories/design-proposals/task-row-design.stories.tsx new file mode 100644 index 00000000000..7c1b1309c56 --- /dev/null +++ b/ui/storybook/stories/design-proposals/task-row-design.stories.tsx @@ -0,0 +1,354 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useArgs } from "storybook/preview-api"; +import { Fragment, useState, type ReactNode } from "react"; +import { ChevronDown, ChevronRight, GitBranch } from "lucide-react"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import type { Issue } from "@paperclipai/shared"; +import { IssueRow } from "@/components/IssueRow"; +import { TaskRowGroup } from "@/components/TaskRowGroup"; +import { TaskList } from "@/components/TaskList"; +import { TaskSummaryList } from "@/components/TaskSummaryList"; +import { allTaskListColumns, defaultTaskListSort, sortTaskListItems, type TaskListSort } from "@/lib/task-list"; +import { buildIssueTree } from "@/lib/issue-tree"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useLocation } from "@/lib/router"; +import { timeAgo } from "@/lib/timeAgo"; +import { chatTask, columnData, connectorParent, healthTask, longTitle, ownerFor, readTask, recoveryChild, reviewTaskItems, sortValue, treeTasks, unreadTask } from "../../support/design-proposals/task-list-data"; + +type Density = "comfortable" | "compact"; +type ContainerWidth = "standard" | "narrow" | "smallest"; +type PlaygroundProps = { + density: Density; + containerWidth: ContainerWidth; + onDensityChange?: (density: Density) => void; + onWidthChange?: (width: ContainerWidth) => void; +}; + +function ReviewFrame({ title, detail, children, wide = false }: { title: string; detail: string; children: ReactNode; wide?: boolean }) { + const location = useLocation(); + return ( +
+ +
+
+

Paperclip · Design system · Proposed

+

{title}

+

{detail}

+
+ {children} +
+
+ ); +} + +function Row({ + issue, + density = "comfortable", + unread = false, + depth = 0, + expanded, + onToggle, + owner = "Codex", + allColumns = false, +}: { + issue: Issue; + density?: Density; + unread?: boolean; + depth?: number; + expanded?: boolean; + onToggle?: () => void; + owner?: string; + allColumns?: boolean; +}) { + const data = columnData(issue, owner); + const { parent } = data; + const rowIssue = allColumns ? data.issue : issue; + const rowLabels = rowIssue.labels ?? []; + return ( + 0 && Boolean(onToggle)} + leadingControl={onToggle ? ( + + ) : undefined} + metadata={{owner}} + trailingMeta="2h ago" + columnValues={allColumns ? { + createdBy: data.creator, + assignee: data.assignee ?? "Unassigned", + project: rowIssue.project?.name, + parentTask: parent ? {parent.identifier} {parent.title} : undefined, + tags: rowLabels.length ? {rowLabels.map(label => {label.name})} : undefined, + lastUpdated: timeAgo(rowIssue.updatedAt), + worktree: rowIssue.currentExecutionWorkspace?.branchName ? {rowIssue.currentExecutionWorkspace.branchName} : undefined, + } : undefined} + /> + ); +} + +function TaskTree({ density = "comfortable", allColumns = false, sort = defaultTaskListSort }: { density?: Density; allColumns?: boolean; sort?: TaskListSort }) { + const [collapsed, setCollapsed] = useState>(new Set()); + const { roots, childMap } = buildIssueTree(sortTaskListItems(treeTasks, sort, sortValue)); + const renderBranch = (issue: Issue, depth: number): ReactNode => { + const children = childMap.get(issue.id) ?? []; + const expanded = !collapsed.has(issue.id); + return ( + + setCollapsed(current => { + const next = new Set(current); + if (next.has(issue.id)) next.delete(issue.id); else next.add(issue.id); + return next; + }) : undefined} /> + {expanded ? children.map(child => renderBranch(child, depth + 1)) : null} + + ); + }; + return ( +
+ {roots.map(issue => ( + + {renderBranch(issue, 0)} + + ))} +
+ ); +} + +function InteractiveColumns() { + return ( + +
+ +

An em dash means no value. Column choices and sorting are local to this preview. Task includes status and title. Reset defaults shows Task, Task ID, and Last updated.

+
+
+ ); +} + +function StateSamples({ density = "comfortable" }: { density?: Density }) { + return ( +
+
+

Read and unread

+

Hover either row: the title becomes foreground and the row background changes. Weight continues to indicate unread status.

+
+
+
+

Read · Regular

+ +
+
+

Unread · Semibold

+ +
+
+
+ ); +} + +function TaskRowSpecimen({ density = "comfortable", containerWidth = "standard", onDensityChange, onWidthChange }: PlaygroundProps) { + const widthClass = { standard: "w-full", narrow: "w-96", smallest: "w-64" }[containerWidth]; + return ( + +
+
+ Row density + {(["comfortable", "compact"] as const).map(value => )} +
+
+ Container width + {(["standard", "narrow", "smallest"] as const).map(value => )} +
+
+
+
+
+

{density === "comfortable" ? "Comfortable" : "Compact"}

+

{density === "comfortable" ? "44px" : "32px"} rows · 24px nesting

+
+ +

Vertical guides connect nested tasks. One boundary encloses each parent and all its visible descendants; child rows have no horizontal separators.

+
+ +
+
This playground uses the shared task-row component with fixture data. Narrow containers show status, title and timestamp; the smallest containers show status and title. Fixed stories remain available as review references. Review the full composition under Design proposals → Pages → Tasks.
+
+ ); +} + +const meta = { + id: "tasks-row-design", + title: "Design proposals/Components/Task list", + component: TaskRowSpecimen, + args: { density: "comfortable", containerWidth: "standard" }, + argTypes: { + density: { control: "inline-radio", options: ["comfortable", "compact"] }, + containerWidth: { control: "inline-radio", options: ["standard", "narrow", "smallest"] }, + onDensityChange: { control: false, table: { disable: true } }, + onWidthChange: { control: false, table: { disable: true } }, + }, + parameters: { + layout: "fullscreen", + controls: { include: [] }, + docs: { + description: { + component: "Proposed task-row design using production IssueRow and TaskRowGroup. Comfortable and compact share one component. Read/unread typography is independent of hover; nested guides and group boundaries follow the parent hierarchy. Use real pointer hover to inspect the four visual combinations. Play assertions cover typography, disclosure, and full-title access; fixtures do not establish production navigation or persistence.", + }, + }, + paperclip: { + capabilities: ["tasks/organize"], + status: "proposed", + home: "design-proposals", + exception: "Staged design-system refresh awaiting visual review and app adoption; kept separate from production references.", + implementation: "production", + boundary: "fixtures", + notes: "Library specimen of refreshed IssueRow, TaskRowGroup, and TaskListColumnHeader. Task combines status and title under one header and visibility choice; Task sorting uses titles, with no status sort. All Columns starts with every column and demonstrates sibling hierarchy, sorting, pointer/keyboard column reordering, pinned column settings, visibility and reset using local fixture state. Last updated is last by default; the flyout follows the current column order. Reset restores that order and shows only Task, Task ID, and Last updated. The Columns flyout follows the Paper reference with compact 32px items, regular labels, and right-aligned checks. Sorted headers retain their background. Table typography uses sm with a 16px line height; every truncated cell uses a local marquee. The entire proposal canvas uses Paper's background token. Status tooltips use shared labels. The composition is under review and does not establish production API, preference persistence, or full accessibility acceptance.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Keep the export stable so existing specimen links continue to work. */ +export const Specimen: Story = { + name: "Playground", + parameters: { controls: { include: ["density", "containerWidth"] } }, + render: function Playground(args) { + const [, updateArgs] = useArgs(); + return updateArgs({ density })} onWidthChange={containerWidth => updateArgs({ containerWidth })} />; + }, +}; + +export const AllColumns: Story = { + render: () => , +}; + +export const Summary: Story = { + name: "Narrow", + parameters: { + paperclip: { + capabilities: ["tasks/organize", "activity/overview"], + status: "proposed", home: "design-proposals", implementation: "production", boundary: "fixtures", + exception: "Reusable narrow summary staged with the task-list proposal; Dashboard opts in only in the local integration sandbox.", + notes: "TaskSummaryList renders the same refreshed IssueRow in a flat, read-style list. It preserves 44px density, sm typography, hover, status tooltip, cell-local marquee and full task navigation. Task ID hides below a 28rem container; time hides below 20rem. Rendering does not mark tasks read. The caller owns ordering and data; no table headers, column controls, query controller or hierarchy are introduced. The normal Dashboard remains unchanged when the design sandbox is off.", + }, + }, + render: () => +
+

Standard · Status, title, task ID, last updated

+ +
+
+

Narrow · Status, title, timestamp

+ +
+
+

Smallest · Status and title

+ +
+
, +}; + +export const Comfortable: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const rootControl = canvas.getByRole("button", { name: "Collapse Improve connector reliability" }); + await userEvent.click(rootControl); + await expect(canvas.queryByRole("link", { name: /Open PAP-429:/ })).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Expand Improve connector reliability" })); + await expect(canvas.getByRole("link", { name: /Open PAP-429:/ })).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Collapse Design reconnect and recovery flows" })); + await expect(canvas.queryByRole("link", { name: /Open PAP-431:/ })).not.toBeInTheDocument(); + await expect(canvas.getByRole("link", { name: /Open PAP-432:/ })).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Expand Design reconnect and recovery flows" })); + await expect(canvas.getByRole("link", { name: /Open PAP-431:/ })).toBeVisible(); + for (const row of canvasElement.querySelectorAll('[data-slot="task-row"]')) { + await expect(row.getBoundingClientRect().height).toBe(44); + } + // Restore an unfocused, unhovered specimen for subsequent visual review. + (canvasElement.ownerDocument.activeElement as HTMLElement | null)?.blur(); + await userEvent.unhover(rootControl); + }, +}; + +export const Compact: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + for (const row of canvasElement.querySelectorAll('[data-slot="task-row"]')) { + await expect(row.getBoundingClientRect().height).toBe(32); + } + }, +}; + +function SingleState({ unread }: { unread: boolean }) { + return ( + + + + ); +} + +async function checkTitleAccess(canvasElement: HTMLElement, unread: boolean) { + const canvas = within(canvasElement); + const link = canvas.getByRole("link", { name: `Open PAP-431: ${longTitle}` }); + const row = link.closest('[data-slot="task-row"]')!; + const title = row.querySelector('[data-slot="task-row-title"]')!; + const text = title.querySelector('[data-slot="task-row-title-text"]')!; + await expect(getComputedStyle(title).fontWeight).toBe(unread ? "600" : "400"); + await expect(text.scrollWidth).toBeGreaterThan(title.clientWidth); + await userEvent.pointer({ target: link, coords: { clientX: title.getBoundingClientRect().left + title.clientWidth / 2, clientY: title.getBoundingClientRect().top + title.clientHeight / 2 } }); + await expect(title).toHaveAttribute("data-revealing", "true"); + await expect(within(canvasElement.ownerDocument.body).queryByRole("tooltip")).not.toBeInTheDocument(); + await userEvent.unhover(link); + await expect(title).not.toHaveAttribute("data-revealing"); + link.focus(); + await expect(link).toHaveFocus(); + await waitFor(() => expect(title).toHaveAttribute("data-revealing", "true")); + link.blur(); + await userEvent.keyboard("{Escape}"); +} + +export const Read: Story = { + render: () => , + play: async ({ canvasElement }) => checkTitleAccess(canvasElement, false), +}; + +export const Unread: Story = { + render: () => , + play: async ({ canvasElement }) => checkTitleAccess(canvasElement, true), +}; diff --git a/ui/storybook/stories/design-proposals/tasks-reproduction-test-2.stories.tsx b/ui/storybook/stories/design-proposals/tasks-reproduction-test-2.stories.tsx new file mode 100644 index 00000000000..087a5ff1d67 --- /dev/null +++ b/ui/storybook/stories/design-proposals/tasks-reproduction-test-2.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TasksReproductionTrial2 } from "../../support/design-proposals/TasksReproductionTrial2"; + +const meta = { + id: "design-agent-task-list-trial-2", + title: "Design proposals/AI Generated Test Pages/Tasks reproduction test 2", + component: TasksReproductionTrial2, + parameters: { + layout: "fullscreen", + initialEntries: ["/PAP/issues"], + paperclip: { + capabilities: ["tasks/organize", "inbox-search/search"], + home: "design-proposals", + status: "proposed", + exception: "Independent second reproduction trial of the proposed desktop Tasks page, pending human visual approval.", + implementation: "prototype", + boundary: "fixtures", + notes: "Independent page composition using DesignPageShell, DesignSidebar, TaskListToolbar, TaskList, KanbanBoard, canonical dialogs and useTaskListCollection. Reuses the shared fixed task data and owner-access fixture. Search, creator identities, filters, views and expansion are delegated to the shared controller; list columns and sorting remain owned by TaskList. View and sidebar preferences use trial-specific storage keys. Navigation and mutations are fixtures; backend behavior and mobile design are outside this trial.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Default: Story = {}; diff --git a/ui/storybook/stories/design-proposals/tasks-reproduction-test.stories.tsx b/ui/storybook/stories/design-proposals/tasks-reproduction-test.stories.tsx new file mode 100644 index 00000000000..3abccebc5ed --- /dev/null +++ b/ui/storybook/stories/design-proposals/tasks-reproduction-test.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TasksReproductionTrial } from "../../support/design-proposals/TasksReproductionTrial"; + +const meta = { + id: "design-agent-task-list-trial", + title: "Design proposals/AI Generated Test Pages/Tasks reproduction test", + component: TasksReproductionTrial, + parameters: { + layout: "fullscreen", + initialEntries: ["/PAP/issues"], + paperclip: { + capabilities: ["tasks/organize", "inbox-search/search"], + status: "proposed", + home: "design-proposals", + exception: "Independent reproduction trial of the provisional Tasks reference; human visual approval and production adoption remain pending.", + implementation: "production", + boundary: "fixtures", + notes: "Composes shared DesignPageShell, DesignSidebar, TaskListToolbar and TaskList without page-preview imports or custom visual markup. Reuses canonical organization/account menus, filters, board, composer and command palette. Search/view/filter matching, namespaced creator options and expansion use the same useTaskListCollection hook as the reference; only fixture data updates remain local. This is a repaired regression reference, not the untouched first submission; original evidence is retained separately. Boards use matching tasks, while the list includes ancestor context. Width and view preferences use isolated trial keys. Destination routes, backend persistence, mobile layout and production adoption are outside this trial.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { parameters: { __id: "design-agent-task-list-trial" } }; diff --git a/ui/storybook/stories/design-proposals/toolbars.stories.tsx b/ui/storybook/stories/design-proposals/toolbars.stories.tsx new file mode 100644 index 00000000000..2f49322273c --- /dev/null +++ b/ui/storybook/stories/design-proposals/toolbars.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useArgs } from "storybook/preview-api"; +import { ToolbarsPreview, type ToolbarsPreviewProps } from "../../support/design-proposals/ToolbarsPreview"; + +const meta = { + id: "design-toolbars", + title: "Design proposals/Components/Toolbars", + component: ToolbarsPreview, + args: { family: "all", width: "full" }, + argTypes: { + family: { control: "select", options: ["all", "tasks", "decisions", "skills", "artifacts", "projects", "routines"] }, + width: { control: "inline-radio", options: ["full", "medium", "narrow"] }, + onWidthChange: { control: false }, + }, + parameters: { + layout: "fullscreen", initialEntries: ["/PAP/issues"], + docs: { description: { component: "Canonical Design Proposals toolbar references. TaskListToolbar is the shared proposal and sandbox Tasks control surface; DecisionsToolbar is the shipped WhatNeedsMe and DecisionQueuePage search/filter/group/sort control set. SkillsToolbar, ArtifactsToolbar ProjectsToolbar and RoutinesToolbar are shared with the shipped pages. Interactive local fixtures preserve real component/menu behavior. Width controls expose wrapping. Backend actions and production adoption are not acceptance claims." } }, + paperclip: { + capabilities: ["tasks/organize", "inbox-search/views", "inbox-search/decisions", "skills/discovery", "documents-artifacts/library", "projects/lifecycle", "routines/management"], + home: "design-proposals", status: "proposed", + exception: "Canonical toolbar families staged in Design Proposals for system reuse and styling before production adoption.", + implementation: "mixed", boundary: "fixtures", + notes: "Mounts TaskListToolbar with useTaskListCollection and DecisionsToolbar directly. All use the proposal theme and refreshed flyouts. Search, task view/layout/expansion, filters, decision grouping/sorting update local state. Task view preference uses an isolated story storage key. Create callbacks only report intent. Existing page and button stories retain their IDs and reuse the same source components. SkillsToolbar and ArtifactsToolbar also render directly; their pages consume the same extracted presentation with existing controllers unchanged. Scan busy/completed state is manually simulated. Skills mounts the shared Installed/Discover/My Skills route links below its controls, with Installed active; Sources remains in New > Manage sources. Link destinations are fixtures, not page-navigation acceptance. Canonical controls share ToolbarSearch, 44px height, opaque border, radius, padding and gap under the proposal theme. Control rows begin with search and end with the primary action when present. Tasks retains its view/heading dropdown before search with the same four persisted choices. ProjectsToolbar shares search, existing sort fields/directions and the create callback with Projects. Decisions search uses the same client-side matching helper as the desk and queue pages, with synthetic decision data. Artifact type filters retain their existing tab semantics; geometry is normalized inside the toolbar. No backend changes.", + }, + }, + render: function Render(args) { + const [, updateArgs] = useArgs(); + return updateArgs({ width })} />; + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; +export const Tasks: Story = { args: { family: "tasks" } }; +export const Decisions: Story = { args: { family: "decisions" } }; +export const Narrow: Story = { args: { width: "narrow" } }; + +export const Skills: Story = { args: { family: "skills" } }; +export const Artifacts: Story = { args: { family: "artifacts" } }; + +export const Projects: Story = { args: { family: "projects" } }; + +export const Routines: Story = { args: { family: "routines" } }; diff --git a/ui/storybook/stories/design-proposals/typography.stories.tsx b/ui/storybook/stories/design-proposals/typography.stories.tsx new file mode 100644 index 00000000000..e2855592ff9 --- /dev/null +++ b/ui/storybook/stories/design-proposals/typography.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TypographyPreview } from "../../support/design-proposals/TypographyPreview"; +import { ConversationTypeSpec } from "../../support/design-proposals/ConversationTypeSpec"; +import { ConversationTypographyPreview } from "../../support/design-proposals/ConversationTypographyPreview"; + +const meta = { + id: "design-foundations-typography", + title: "Design proposals/Foundations/Typography", + component: TypographyPreview, + parameters: { + layout: "fullscreen", + docs: { description: { component: "Proposed type ramp and source-derived typography roles for Design Proposals and the local design sandbox. Live measurements distinguish current CSS behavior from proposed guidance. Includes shared TaskList, MetricCard and MarkdownBody examples; the shared proposal typography also applies to the opt-in sandbox." } }, + paperclip: { + capabilities: [], + home: "design-proposals", + status: "proposed", + exception: "Cross-cutting typography foundation for Design Proposals and its sandbox integration; not a production feature acceptance story.", + implementation: "guide", + boundary: "fixtures", + notes: "Documents the proposed nine-step 10–30px ramp, Inter/Paper Mono families, normal/semibold weights, emphasis, overflow and source-derived roles. Recipe specimens show existing classes/tokens; integrated TaskList, MetricCard and MarkdownBody samples mount real shared components with synthetic text. Conversation styles mounts DesignSandboxTaskThread directly with its complete 72-state fixture catalog, grouped preview selector, replay, activity disclosures and local composer actions. Optional typography measurements inspect the rendered composition on demand. Browser-computed metrics are local measurements, not a production-wide runtime audit. Existing out-of-ramp Markdown, metadata sizes and muted headings remain explicit review items. Navigation and streamlined property sections share one label recipe; previous micro usages are xs. Dark desktop reference, with responsive behavior noted; no provider actions or ordinary-theme adoption. Conversation regions use shared 14px/20px body and 12px/16px metadata recipes, with muted supporting text, contrasting human bubbles and foreground final replies.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const TypeSpecification: Story = { name: "Type specification" }; +export const ConversationStyles: Story = { + name: "Conversation styles", + render: () => , + parameters: { + initialEntries: ["/DES/issues/DES-7?threadPreview=all"], + docs: { description: { story: "The exact DesignSandboxTaskThread composition used by the sandbox IssueDetail page, with all 72 canonical synthetic states and the same grouped selector, replay, transcript/native renderer paths, history, activity disclosures, requests and composer. Defaults to All states. Optional measurements inspect its rendered typography; refresh after changing state or opening disclosures. Saved backend conversation data is not loaded in Storybook. Preview actions remain local. Production renderer reuse does not verify a deployed production version, and the shared proposal conversation typography is applied." } }, + }, +}; + +export const ConversationTypeSpecification: Story = { + name: "Conversation type spec", + render: () => , + parameters: { + initialEntries: ["/DES/issues/DES-7?threadPreview=all"], + docs: { description: { story: "A measured conversation typography specification in the same layout as Type specification. Mounts the complete shared 72-state sandbox preview and inventories currently rendered text into size, family, weight and surface/recipe rows. Includes search, surface filtering, shared-metric consolidation candidates, source classes, example-state links and explicit inventory refresh after disclosures or state changes. Isolated specimens copy browser-computed type properties; the real composition remains in context. Closed content, placeholders and absent Markdown structures are not counted. Measures the consolidated proposal conversation typography; no backend action is applied." } }, + }, +}; + +export const ConversationPlayback: Story = { + name: "Conversation playback", + render: () => , + parameters: { + docs: { description: { story: "Watch two exchanges in one continuous synthetic conversation using the shared sandbox playback controller and production transcript renderers. Play, pause, resume and replay are local. Thinking, commentary, tools and a final answer lead into a follow-up with queuing, planning, clarification, permission, a failed test and retry, a file preview, usage and another final answer. Demo answers and permission receipts advance automatically; request controls are disabled. New words fade over 320ms; running tool text shimmers white and final-channel text streams in foreground. Completed tools return to muted metadata. Reduced motion suppresses decorative motion. No provider, agent, command or backend mutation is triggered. The separate Conversation styles and type spec stories retain all 72 inspectable fixtures." } }, + }, +}; diff --git a/ui/storybook/support/design-proposals/ActivityListPreview.tsx b/ui/storybook/support/design-proposals/ActivityListPreview.tsx new file mode 100644 index 00000000000..d421613c4d9 --- /dev/null +++ b/ui/storybook/support/design-proposals/ActivityListPreview.tsx @@ -0,0 +1,75 @@ +import { useLayoutEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { ActivityEvent } from "@paperclipai/shared"; +import { ActivityList } from "@/components/ActivityList"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { FlyoutAppearanceProvider } from "@/components/ui/flyout"; +import { queryKeys } from "@/lib/queryKeys"; +import { storybookActivityEvents, storybookAgentMap, storybookProjects } from "../../fixtures/paperclipData"; +import { recoveryChild } from "./task-list-data"; + +type Density = "comfortable" | "compact"; +type Width = "standard" | "narrow" | "smallest"; +export type ActivityListPreviewProps = { + density?: Density; + containerWidth?: Width; + withReferences?: boolean; + onDensityChange?: (density: Density) => void; + onWidthChange?: (width: Width) => void; +}; + +const project = storybookProjects[0]!; +const base: ActivityEvent = { ...storybookActivityEvents[0]!, actorType: "user", actorId: "board", createdAt: new Date("2026-10-15T00:00:00Z") }; +const events: ActivityEvent[] = [ + { ...base, id: "activity-task", action: "issue.updated", entityType: "issue", entityId: recoveryChild.id }, + { ...base, id: "activity-agent", action: "agent.paused", entityType: "agent", entityId: "agent-codex" }, + { ...base, id: "activity-project", actorType: "agent", actorId: "agent-codex", action: "project.updated", entityType: "project", entityId: project.id }, + { ...base, id: "activity-approval", action: "approval.revision_requested", entityType: "approval", entityId: "approval-budget", details: { type: "budget_override_required" } }, + { ...base, id: "activity-run", action: "heartbeat.invoked", entityType: "heartbeat_run", entityId: "run-storybook", details: { agentId: "agent-codex" } }, + { ...base, id: "activity-system", actorType: "system", actorId: "system", action: "company.updated", entityType: "company", entityId: "company-storybook", details: null }, +]; + +const names = new Map([ + [`issue:${recoveryChild.id}`, recoveryChild.identifier!], + ["agent:agent-codex", "CodexCoder"], + [`project:${project.id}`, project.name], + ["approval:approval-budget", "Monthly budget adjustment"], + ["company:company-storybook", "Paperclip Storybook"], +]); +const titles = new Map([[`issue:${recoveryChild.id}`, recoveryChild.title]]); +const profiles = new Map([["board", { label: "Riley Board", image: null }]]); + +export function ActivityListPreview({ density = "comfortable", containerWidth = "standard", withReferences = false, onDensityChange, onWidthChange }: ActivityListPreviewProps) { + const client = useQueryClient(); + useLayoutEffect(() => { + for (const ref of [recoveryChild.id, recoveryChild.identifier!]) client.setQueryData(queryKeys.issues.detail(ref), recoveryChild); + }, [client]); + const items = withReferences ? [{ ...events[0]!, details: { addedReferencedIssues: [{ id: recoveryChild.id, identifier: recoveryChild.identifier, title: recoveryChild.title }], removedReferencedIssues: [{ id: "old-task", identifier: "PAP-400", title: "Superseded connector plan" }] } }, ...events.slice(1)] : events; + const widthClass = { standard: "w-full", narrow: "w-96", smallest: "w-64" }[containerWidth]; + return +
+
+
+

Paperclip · Design system · Proposed

+

Activity list

+

Task, agent, project, approval, run and system activity. Rows share task-list density, type, fade and cell-local marquee. Task entries retain their hover card; other entries keep their own destination.

+
+
+
+ Row density + {(["comfortable", "compact"] as const).map(value => )} +
+
+ Container width + {(["standard", "narrow", "smallest"] as const).map(value => )} +
+
+ + + + {withReferences ?

Reference changes remain below the fixed-height row. Their links keep their existing navigation and accessible labels.

: null} +
+
+
; +} diff --git a/ui/storybook/support/design-proposals/AgentWorkloadTrial.tsx b/ui/storybook/support/design-proposals/AgentWorkloadTrial.tsx new file mode 100644 index 00000000000..22517f0c591 --- /dev/null +++ b/ui/storybook/support/design-proposals/AgentWorkloadTrial.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState } from "react"; +import type { Agent, Issue } from "@paperclipai/shared"; +import { CirclePause, Coffee, ListChecks, SearchX, Users, Zap } from "lucide-react"; +import { AgentAvatar } from "@/components/AgentAvatar"; +import { CollectionToolbar } from "@/components/CollectionToolbar"; +import { CommandPalette } from "@/components/CommandPalette"; +import { DesignPageShell } from "@/components/DesignPageShell"; +import { DesignSidebar } from "@/components/DesignSidebar"; +import { EmptyState } from "@/components/EmptyState"; +import { MetricCard } from "@/components/MetricCard"; +import { NewIssueDialog } from "@/components/NewIssueDialog"; +import { AgentStatusCapsule } from "@/components/StatusBadge"; +import { TabFilters } from "@/components/TabBar"; +import { TaskRowGroup } from "@/components/TaskRowGroup"; +import { TaskSummaryList } from "@/components/TaskSummaryList"; +import { ToolbarSearch } from "@/components/ToolbarSearch"; +import { SidePanelWindowControls } from "@/components/side-panel"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { useCompany } from "@/context/CompanyContext"; +import { useDialogActions } from "@/context/DialogContext"; +import { DesignProposalAccess } from "./DesignProposalAccess"; +import { workloadAgents, workloadTasks } from "./agent-workload-trial-data"; +import "./agent-workload-trial.css"; + +type WorkloadState = "working" | "idle" | "paused"; +type WorkloadFilter = "all" | WorkloadState; +export type AgentWorkloadTrialProps = { + initialQuery?: string; + initialFilter?: WorkloadFilter; + initialAgentId?: string; + emptyOrganization?: boolean; +}; + +const filters = [ + { value: "all", label: "All" }, { value: "working", label: "Working" }, + { value: "idle", label: "Idle" }, { value: "paused", label: "Paused" }, +] as const; +const stateLabels = { working: "Working", idle: "Idle", paused: "Paused" }; +const workloadState = (agent: Agent): WorkloadState => agent.status === "running" ? "working" : agent.status === "paused" ? "paused" : "idle"; +const activeTask = (task: Issue) => task.status === "in_progress"; +const queuedTask = (task: Issue) => task.status === "todo" || task.status === "backlog"; +const newestFirst = (a: Issue, b: Issue) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + +/** The caller supplies workload membership; row presentation stays entirely shared. */ +function TaskSection({ title, tasks, label, emptyMessage }: { title: string; tasks: Issue[]; label: string; emptyMessage: string }) { + return
+

{title} {tasks.length}

+ {tasks.length ? + :

{emptyMessage}

} + {tasks.length > 6 &&

Showing the six most recently updated tasks.

} +
; +} + +function AgentWorkloadContent({ initialQuery = "", initialFilter = "all", initialAgentId, emptyOrganization = false }: AgentWorkloadTrialProps) { + const { selectedCompanyId } = useCompany(); + const { openNewIssue } = useDialogActions(); + const [query, setQuery] = useState(initialQuery); + const [filter, setFilter] = useState(initialFilter); + const [selectedId, setSelectedId] = useState(initialAgentId ?? null); + const [maximized, setMaximized] = useState(false); + const searchRef = useRef(null); + const cardRefs = useRef(new Map()); + const agents = emptyOrganization ? [] : workloadAgents.filter(agent => agent.companyId === selectedCompanyId); + const tasks = workloadTasks.filter(task => task.companyId === selectedCompanyId && agents.some(agent => agent.id === task.assigneeAgentId)); + const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + const visibleAgents = agents.filter(agent => (filter === "all" || workloadState(agent) === filter) + && words.every(word => `${agent.name} ${agent.role} ${agent.title ?? ""}`.toLocaleLowerCase().includes(word))); + const selectedAgent = visibleAgents.find(agent => agent.id === selectedId); + const selectedTasks = tasks.filter(task => task.assigneeAgentId === selectedAgent?.id).toSorted(newestFirst); + const active = selectedTasks.filter(activeTask); + const recent = selectedTasks.filter(task => !activeTask(task) && !queuedTask(task)); + + useEffect(() => { + if (selectedAgent) document.getElementById("design-page-aside-content")?.focus(); + }, [selectedAgent?.id]); + + const dismiss = () => { + const returnId = selectedId; + setSelectedId(null); + setMaximized(false); + requestAnimationFrame(() => { if (returnId) cardRefs.current.get(returnId)?.focus(); }); + }; + const changeQuery = (next: string) => { setQuery(next); setSelectedId(null); setMaximized(false); }; + const changeFilter = (next: WorkloadFilter) => { setFilter(next); setSelectedId(null); setMaximized(false); }; + const clear = () => { changeQuery(""); setFilter("all"); searchRef.current?.focus(); }; + + return
{ + if (event.key === "Escape" && !event.defaultPrevented && selectedAgent) { event.preventDefault(); dismiss(); } + }}> + openNewIssue()} />} + rightSidebarTitle="Agent tasks" + rightSidebarMaximized={maximized} + rightSidebarControls={} + rightSidebar={selectedAgent && <> +
+ +

{selectedAgent.name}

+

{selectedAgent.title}

+

{stateLabels[workloadState(selectedAgent)]}

+ {selectedAgent.status === "paused" &&

Work is paused. Queued tasks stay assigned.

} +
+ {selectedTasks.length ? <> + + +

{selectedTasks.filter(queuedTask).length} queued tasks · Todo and backlog

+ : } + } + > +
+ workloadState(agent) === "working").length} label="Working agents" description="Currently running" /> + workloadState(agent) === "idle").length} label="Idle agents" description="Available for work" /> + workloadState(agent) === "paused").length} label="Paused agents" description="Work on hold" /> +
+ } + controls={} + feedback={

{visibleAgents.length} of {agents.length} agents · {selectedAgent ? "Viewing assigned work" : "Select an agent to inspect its tasks"}

} + /> + {agents.length === 0 ? + : visibleAgents.length === 0 ? + :
    + {visibleAgents.map(agent => { + const assigned = tasks.filter(task => task.assigneeAgentId === agent.id); + return
  • + + + +
  • ; + })} +
} +
+ + +
; +} + +export function AgentWorkloadTrial(props: AgentWorkloadTrialProps) { + const { selectedCompanyId } = useCompany(); + return ; +} diff --git a/ui/storybook/support/design-proposals/AppShellPreview.tsx b/ui/storybook/support/design-proposals/AppShellPreview.tsx new file mode 100644 index 00000000000..d1318726eca --- /dev/null +++ b/ui/storybook/support/design-proposals/AppShellPreview.tsx @@ -0,0 +1,103 @@ +import { CommandPalette } from "@/components/CommandPalette"; +import { DesignPageShell } from "@/components/DesignPageShell"; +import { DesignSidebar } from "@/components/DesignSidebar"; +import { NewIssueDialog } from "@/components/NewIssueDialog"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + SidePanelLauncher, SidePanelTabs, SidePanelToggleButton, SidePanelWindowControls, + useSidePanelTabs, type SidePanelTabRecord, +} from "@/components/side-panel"; +import { useDialogActions } from "@/context/DialogContext"; +import { DesignProposalAccess } from "./DesignProposalAccess"; +import { recentTasks } from "./task-page-data"; + +export type AppShellPreviewProps = { + rightSidebar: boolean; + overflowingContent: boolean; +}; + +const availableTabs: SidePanelTabRecord[] = ["Properties", "Tasks", "Plan", "Artifacts"].map(label => ({ + id: `app-shell-${label.toLowerCase()}`, type: "placeholder", label, closable: true, payload: null, +})); + +function ContentBlocks({ count, label }: { count: number; label: string }) { + return
+ {Array.from({ length: count }, (_, index) => + +

{label} {index + 1}

+

Placeholder content supplied by the page.

+
+
)} +
; +} + +function AppShellContent({ rightSidebar, overflowingContent }: AppShellPreviewProps) { + const { openNewIssue } = useDialogActions(); + const [panelOpen, setPanelOpen] = useState(true); + const [maximized, setMaximized] = useState(false); + const panelToggleRef = useRef(null); + const controller = useSidePanelTabs({ initialState: { tabs: availableTabs, activeTabId: availableTabs[0].id } }); + const activeTab = controller.tabs.find(tab => tab.id === controller.activeTabId); + const launcherSections = [{ + id: "panels", label: "Panels", + items: availableTabs.map(tab => ({ id: tab.id, label: tab.label, alreadyOpen: controller.tabs.some(openTab => openTab.id === tab.id) })), + }]; + const openTab = ({ id }: { id: string }) => { + const tab = availableTabs.find(candidate => candidate.id === id); + if (tab) controller.openTab(tab); + }; + const closePanel = () => { + setPanelOpen(false); + setMaximized(false); + requestAnimationFrame(() => panelToggleRef.current?.querySelector("button")?.focus()); + }; + const variant = rightSidebar ? "three-panes" : "two-panes"; + return <> + item.issue)} onNewTask={() => openNewIssue()} />} + headerActions={rightSidebar && panelOpen ? closePanel() : setPanelOpen(true)} />} + rightSidebarTitle="Right sidebar" + rightSidebarMaximized={maximized} + rightSidebarHeader={} + />} + />} + rightSidebarControls={} + rightSidebar={rightSidebar && panelOpen ? activeTab ?
+

{activeTab.label}: details, context, or tools supplied by the page. This pane scrolls independently.

+ +
: : undefined} + > +
+

Main content

+

{rightSidebar ? "Left sidebar, main content, and right sidebar." : "Left sidebar and main content."} The same shell is used by the Tasks page.

+

Resize a sidebar by dragging its divider or using the arrow keys on the focused divider. Double-click a divider to smoothly restore its default width. The main pane fills the remaining space; pane headers stay fixed while their content scrolls.

+
+ +
+ + + ; +} + +export function AppShellPreview(props: AppShellPreviewProps) { + return ; +} +import { useRef, useState } from "react"; +import { Plus } from "lucide-react"; diff --git a/ui/storybook/support/design-proposals/ButtonSamples.tsx b/ui/storybook/support/design-proposals/ButtonSamples.tsx new file mode 100644 index 00000000000..d3bb22b04dc --- /dev/null +++ b/ui/storybook/support/design-proposals/ButtonSamples.tsx @@ -0,0 +1,217 @@ +import { useState, type ComponentProps, type ReactNode } from "react"; +import type { FeedbackVoteValue } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { ClearErrorButton, PauseResumeButton, RunButton } from "@/components/AgentActionButtons"; +import { DesignSidebar } from "@/components/DesignSidebar"; +import { ArtifactUploadButton, ArtifactUploadContent } from "@/components/artifacts/ArtifactUploadControls"; +import { ExpandRelationListButton } from "@/components/issue-properties/relation-controls"; +import { FilePreviewModeToggle, type FilePreviewMode } from "@/components/FilePreviewModeToggle"; +import { OutputFeedbackButtons } from "@/components/OutputFeedbackButtons"; +import { StarToggle } from "@/components/StarToggle"; +import { StatusIcon } from "@/components/StatusIcon"; +import { TaskList } from "@/components/TaskList"; +import { TaskListToolbar } from "@/components/TaskListToolbar"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { FlyoutAppearanceProvider } from "@/components/ui/flyout"; +import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions"; +import { SidePanelToggleButton, SidePanelWindowControls } from "@/components/side-panel/SidePanelFrame"; +import { SidePanelTabs } from "@/components/side-panel/SidePanelTabs"; +import { defaultIssueFilterState } from "@/lib/issue-filters"; +import type { TaskListView } from "@/lib/task-list-views"; +import { storybookAgents, storybookIssueLabels, storybookProjects } from "../../fixtures/paperclipData"; +import { DesignProposalAccess } from "./DesignProposalAccess"; +import { reviewTaskItems } from "./task-list-data"; +import { ButtonSpecimen as SampleButton } from "./ButtonSpecimen"; +import { useButtonPreviewStates } from "./useButtonPreviewStates"; + +type ButtonProps = ComponentProps; +export type ButtonVariant = NonNullable; +export type ButtonSize = NonNullable; +// Exhaustive against the shipped Button API: adding a variant/size requires a +// label here, so the sandbox inventory includes new shared primitive options. +export const buttonVariants = { + default: "Default", cta: "CTA", destructive: "Destructive", outline: "Outline", + secondary: "Secondary", ghost: "Ghost", link: "Link", +} satisfies Record; +export const buttonSizes = { + default: "Default", xs: "Extra small", sm: "Small", lg: "Large", + icon: "Icon", "icon-xs": "Icon extra small", "icon-sm": "Icon small", "icon-lg": "Icon large", +} satisfies Record; +export const buttonStates = { default: "Default", hover: "Hover", "focus-visible": "Keyboard focus", active: "Pressed", disabled: "Disabled", loading: "Loading" } as const; +export const buttonSurfaces = { background: "Main pane / menu", muted: "Left sidebar / raised card", "muted-alt": "Middle card", "background-alt": "Input surface" } as const; +const surfaceClasses = { background: "bg-background", muted: "bg-muted", "muted-alt": "bg-(--muted-alt)", "background-alt": "bg-(--background-alt)" }; +export type ButtonPlaygroundSettings = { + label: string; + variant: ButtonVariant; + size: ButtonSize; + icon: "none" | "leading" | "trailing"; + state: keyof typeof buttonStates; + surface: keyof typeof buttonSurfaces; +}; +export const defaultButtonSettings: ButtonPlaygroundSettings = { + label: "Create task", variant: "outline", size: "default", icon: "leading", state: "default", surface: "background", +}; + +function SampleGroup({ title, source, children }: { title: string; source: string; children: ReactNode }) { + return
+
+

{title}

+

{source}

+
+ {children} +
; +} + +export function ButtonSamples({ settings = defaultButtonSettings }: { settings?: ButtonPlaygroundSettings }) { + const [action, setAction] = useState("No action yet"); + useButtonPreviewStates(); + const act = (name: string) => setAction(`${name} clicked · preview only`); + return
+ +
+ act(settings.variant)} measure /> + {action} +
+

Surface: --{settings.surface}. Hover, keyboard focus and pressed previews reuse the compiled component state rules. Real pointer and keyboard interactions remain available; states without dedicated styling can look identical.

+ {settings.size.startsWith("icon") &&

Icon-only sizes always retain an icon and use the label as their accessible name.

} +
Implementation reference +
{`import { Button } from "@/components/ui/button";\n\n`}
+

Forced interaction states belong to this preview. Use pointer interaction and keyboard focus in the app.

+
+
+ +
+ + + {["Variant", "Default", "Disabled", "Loading"].map(label => )} + + {Object.entries(buttonVariants).map(([variant, label]) => + + {(["default", "disabled", "loading"] as const).map(state => )} + )} +
{label}
{label}{variant} + act(variant)} /> +
+
+
+ +
+ {Object.entries(buttonSizes).map(([size, label]) =>
+ act(size)} measure /> +

{label}{size}

+
)} +
+
+ +
; +} + +function SandboxContexts({ onAction }: { onAction: (label: string) => void }) { + const [view, setView] = useState("all"); + const [query, setQuery] = useState("preview"); + const [layout, setLayout] = useState<"list" | "board">("list"); + const [expanded, setExpanded] = useState(true); + const [filters, setFilters] = useState(defaultIssueFilterState); + return +
+ + setExpanded(value => !value)} + onNewTask={() => onAction("New Task")} filters={{ state: filters, onChange: patch => setFilters(current => ({ ...current, ...patch })), activeFilterCount: filters.statuses.length, + agents: storybookAgents, projects: storybookProjects, labels: storybookIssueLabels }} /> + + + + + +
+ onAction("Sidebar New Task")} /> +
+
+ + + + + + +
+
; +} + +function PanelButtons({ onAction }: { onAction: (label: string) => void }) { + const [open, setOpen] = useState(true); + const [maximized, setMaximized] = useState(false); + const [tabs, setTabs] = useState([{ id: "properties", type: "properties", label: "Properties", closable: true }, { id: "tasks", type: "tasks", label: "Tasks", closable: true }]); + const [active, setActive] = useState("properties"); + return
+
+ setOpen(value => !value)} highlightWhenOpen={false} /> + { setOpen(false); onAction("Close side panel"); }} /> +
+
+ setTabs(current => current.filter(tab => tab.id !== id))} + onAddTab={() => { const id = `preview-${tabs.length}`; setTabs(current => [...current, { id, type: "preview", label: `Preview ${current.length + 1}`, closable: true }]); setActive(id); }} + appearance="streamlined-task" /> +
+
; +} + +function SharedContexts({ onAction }: { onAction: (label: string) => void }) { + const [paused, setPaused] = useState(false); + const [vote, setVote] = useState(null); + const [starred, setStarred] = useState(false); + const [mode, setMode] = useState("rendered"); + const [enabled, setEnabled] = useState(false); + const [status, setStatus] = useState("todo"); + const [relationsExpanded, setRelationsExpanded] = useState(false); + const [uploadVisible, setUploadVisible] = useState(true); + return
+ + onAction("Run now")} /> + setPaused(true)} onResume={() => setPaused(false)} /> + setPaused(true)} onResume={() => setPaused(false)} /> + onAction("Clear error")} /> + {}} /> + + + setVote(value) }} /> + setVote(value)} /> + + + + onAction(`Star ${value}`)} /> + {}} /> + onAction("Retry star")} /> + + + + + + + {}} aria-label="Disabled preview" /> + + + + + + onAction(`Selected ${files.length} files`)} /> + onAction("Retry upload")} onDismiss={() => setUploadVisible(false)} /> + + + setRelationsExpanded(value => !value)} /> + + + + +
; +} + +function ContextRow({ label, source, children }: { label: string; source: string; children: ReactNode }) { + return
+

{label}

+
{children}
+

{source}

+
; +} diff --git a/ui/storybook/support/design-proposals/ButtonSpecimen.tsx b/ui/storybook/support/design-proposals/ButtonSpecimen.tsx new file mode 100644 index 00000000000..acfa10038eb --- /dev/null +++ b/ui/storybook/support/design-proposals/ButtonSpecimen.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from "react"; +import { CirclePlus, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { ButtonPlaygroundSettings, ButtonSize, ButtonVariant } from "./ButtonSamples"; + +type Measurements = { dimensions: string; type: string; padding: string; radius: string; icon: string; colors: string }; +const round = (value: number) => Math.round(value * 10) / 10; + +export function ButtonSpecimen({ settings, variant = settings.variant, size = settings.size, state = settings.state, onClick, measure = false }: { + settings: ButtonPlaygroundSettings; variant?: ButtonVariant; size?: ButtonSize; + state?: ButtonPlaygroundSettings["state"]; onClick: () => void; measure?: boolean; +}) { + const ref = useRef(null); + const [measurements, setMeasurements] = useState(); + const iconOnly = size.startsWith("icon"); + const busy = state === "loading"; + const read = () => { + const element = ref.current; + if (!measure || !element) return; + const box = element.getBoundingClientRect(); + const css = getComputedStyle(element); + const icon = element.querySelector("svg")?.getBoundingClientRect(); + const next = { + dimensions: `${round(box.width)} × ${round(box.height)}px`, + type: `${css.fontSize} / ${css.lineHeight} · weight ${css.fontWeight}`, + padding: `${css.paddingLeft} left · ${css.paddingRight} right · ${css.paddingTop} top · ${css.paddingBottom} bottom`, + radius: css.borderRadius, + icon: icon ? `${round(icon.width)} × ${round(icon.height)}px` : "None", + colors: `${css.color} on ${css.backgroundColor}`, + }; + setMeasurements(previous => JSON.stringify(previous) === JSON.stringify(next) ? previous : next); + }; + useEffect(() => { + if (!measure || !ref.current) return; + let frame = requestAnimationFrame(read); + const observer = new ResizeObserver(() => { cancelAnimationFrame(frame); frame = requestAnimationFrame(read); }); + observer.observe(ref.current); + return () => { cancelAnimationFrame(frame); observer.disconnect(); }; + }, [measure, variant, size, state, settings.icon, settings.label, settings.surface]); + return
+ + {measurements && <> + {measurements.dimensions} +
+ Computed style +
+ {Object.entries({ Typography: measurements.type, Padding: measurements.padding, Radius: measurements.radius, Icon: measurements.icon, Colors: measurements.colors }).map(([label, value]) =>
{label}
{value}
)} +
+
+ } +
; +} diff --git a/ui/storybook/support/design-proposals/ButtonUsage.tsx b/ui/storybook/support/design-proposals/ButtonUsage.tsx new file mode 100644 index 00000000000..4b451898ca4 --- /dev/null +++ b/ui/storybook/support/design-proposals/ButtonUsage.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import inventory from "virtual:paperclip-button-usage"; +import { Input } from "@/components/ui/input"; +import type { ButtonPlaygroundSettings } from "./ButtonSamples"; + +export function ButtonUsage({ settings }: { settings: ButtonPlaygroundSettings }) { + const [filter, setFilter] = useState(""); + const [matchSpecimen, setMatchSpecimen] = useState(false); + const scoped = inventory.entries; + const matches = scoped.filter(entry => (!matchSpecimen || entry.variant === settings.variant && entry.size === settings.size) + && `${entry.file} ${entry.variant} ${entry.size}`.toLowerCase().includes(filter.toLowerCase())); + const files = new Set(scoped.map(entry => entry.file)); + const dynamic = scoped.filter(entry => entry.variant.startsWith("dynamic") || entry.size.startsWith("dynamic")); + const locations = new Map(); + for (const entry of matches) locations.set(entry.file, [...(locations.get(entry.file) ?? []), entry]); + return
+
+

Where it is used

+

Direct references to the shared Button in production-source directory ui/src, scanned from this checkout when Storybook builds. Counts describe source locations; loops, wrappers, feature gates and legacy routes affect rendered totals.

+
+
+
Button call sites
{scoped.length}
+
Source files
{files.size}
+
Dynamic props / spreads
{dynamic.length}
+
Selected style + size
{scoped.filter(entry => entry.variant === settings.variant && entry.size === settings.size).length}
+
+
+ setFilter(event.target.value)} /> + +
+

{matches.length} matching call sites in {locations.size} files. Dynamic values are listed separately and cannot be assigned to a specific style or size.

+
+ Browse source locations +
+ {[...locations].map(([file, entries]) =>
+ {file} +

{entries.map(entry => `L${entry.line}: ${entry.variant} / ${entry.size}`).join(" · ")}

+
)} + {!matches.length &&

No direct references match these filters.

} +
+
+

App source includes staged components and alternate production implementations. Tests, Storybook files, native HTML buttons and indirect uses through wrapper components are excluded. These counts measure adoption in code, not usage analytics.

+
; +} diff --git a/ui/storybook/support/design-proposals/ButtonsPlayground.tsx b/ui/storybook/support/design-proposals/ButtonsPlayground.tsx new file mode 100644 index 00000000000..9e40c330be2 --- /dev/null +++ b/ui/storybook/support/design-proposals/ButtonsPlayground.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { NativeSelect } from "@/components/ui/select"; +import { ButtonSamples, buttonSizes, buttonStates, buttonSurfaces, buttonVariants, defaultButtonSettings, type ButtonPlaygroundSettings } from "./ButtonSamples"; + +export type ButtonsPlaygroundProps = ButtonPlaygroundSettings & { + onSettingsChange?: (settings: Partial) => void; +}; + +export function ButtonsPlayground({ onSettingsChange, ...settings }: ButtonsPlaygroundProps) { + const [sizeHeights, setSizeHeights] = useState>({}); + useEffect(() => { + const buttons = document.querySelectorAll('[data-testid="proposal-button-samples"] [data-button-size] [data-slot="button"]'); + const measure = () => { + const next = Object.fromEntries(Array.from(buttons, button => [button.dataset.size!, Math.round(button.getBoundingClientRect().height * 10) / 10])); + setSizeHeights(previous => JSON.stringify(previous) === JSON.stringify(next) ? previous : next); + }; + let frame = 0; + const observer = new ResizeObserver(() => { cancelAnimationFrame(frame); frame = requestAnimationFrame(measure); }); + buttons.forEach(button => observer.observe(button)); + measure(); + return () => { observer.disconnect(); cancelAnimationFrame(frame); }; + }, []); + return
+
+
+

Paperclip · Design proposals · Components

+

Buttons playground

+

Style the buttons and contextual controls used in the design sandbox. Every example uses its shared component with the sandbox’s proposal theme. Hover and Tab through the controls; actions use local fixture state.

+
+
+ + + + + + + +

Size labels show measured heights. Specimens report their actual width, padding, typography, icon size and colors. The inventory below uses the full available width.

+
+
+

Sandbox buttons

+

Shared styles and sizes, followed by task, navigation, pane, agent, conversation and attachment controls in context.

+
+ +
+

Scope: the sandbox’s shared button styles and sizes, plus the contextual controls shown here. Examples use synthetic data and local callbacks; they do not run agents, upload files or persist product changes. This is a styling workbench, not a repository-wide usage audit.

+
+
; +} diff --git a/ui/storybook/support/design-proposals/ColorsPreview.tsx b/ui/storybook/support/design-proposals/ColorsPreview.tsx new file mode 100644 index 00000000000..5fdf7b3588d --- /dev/null +++ b/ui/storybook/support/design-proposals/ColorsPreview.tsx @@ -0,0 +1,122 @@ +import { useLayoutEffect, useState } from "react"; +import { useTheme } from "@/context/ThemeContext"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { TaskList } from "@/components/TaskList"; +import { readTask, unreadTask } from "./task-list-data"; + +export type ColorsPreviewProps = { section: "tokens" | "surfaces" | "interaction" }; + +const source = "https://app.paper.design/file/01KZ5QQTS4TG2VADP7ZVGJNQY4/p-L-0/3LXU-2"; +const colors = [ + { name: "foreground", usage: "Headings, header labels, emphasized text and unread task titles." }, + { name: "background", usage: "Center and right panes; lowest card surface; popovers and menus." }, + { name: "background-alt", usage: "Text inputs and search fields." }, + { name: "muted", usage: "Left sidebar and highest card surface." }, + { name: "muted-highlight", usage: "Hover backgrounds; existing selected and sorted states keep their documented behavior." }, + { name: "muted-alt", usage: "Middle card surface." }, + { name: "muted-foreground", usage: "Default text, secondary labels, metadata and read task titles." }, + { name: "border", usage: "Shared pane dividers, field outlines and card borders. Uses the active production light border or the translucent Paper dark border." }, + { name: "ring", usage: "Production ring in light mode; foreground in dark mode. Pane resize controls retain their own highlight." }, + { name: "focus-ring-halo", usage: "Soft outer focus halo: active ring at 20% opacity, surrounding the focus edge." }, +] as const; + +function TokenReference() { + const { theme } = useTheme(); + const [values, setValues] = useState>({}); + useLayoutEffect(() => { + const styles = getComputedStyle(document.documentElement); + setValues(Object.fromEntries(colors.map(({ name }) => [name, styles.getPropertyValue(`--${name}`).trim()]))); + }, [theme]); + return ( +
+ + + + {["Swatch", "Code token", "CSS value", "Use"].map(label => )} + + {colors.map(({ name, usage }) => + + + + + )} +
Values come directly from the active CSS token layer. Switch Light / Dark in the Storybook toolbar to compare.
{label}
--{name}{theme === "light" ? "Production light palette" : name === "ring" || name === "focus-ring-halo" ? "Derived from dark-color-foreground" : `Paper reference: dark-color-${name}`}{values[name] ?? "…"}{usage}
+
+ ); +} + +function Surfaces() { + return ( +
+
+

Pane surfaces

+
+ {[ + { label: "Left sidebar", token: "design-sidebar-surface" }, + { label: "Center pane", token: "background" }, + { label: "Right sidebar", token: "background" }, + ].map(({ label, token }) =>
+

{label}

--{token} +
)} +
+
+
+

Card elevation

+

These use the existing Card component and one shared border. Dark mode has three distinct fills; light mode maps middle and high to the same production neutral surface.

+
+ {[ + { elevation: "low", label: "Lowest", token: "background", example: "Task summary" }, + { elevation: "middle", label: "Middle", token: "muted-alt", example: "Review details" }, + { elevation: "high", label: "Highest", token: "muted", example: "Related work" }, + ].map(({ elevation, label, token, example }) => + {label} · {example}--{token} + Content uses the active card foreground. Each card uses --border. + )} +
+
+
+

Text inputs & search

+

Both use --background-alt and the shared border.

+
+ + +
+
+
+ ); +} + +function TextAndInteraction() { + return ( +
+
+

Default and emphasized text

+

Most text uses muted foreground. Headings, header labels and emphasized text use foreground.

+

Read task titles are regular and muted; unread titles are semibold and foreground. Hover makes either title foreground while preserving its weight.

+
+
+

Shared task list & Columns menu

+

Hover a row to see muted-highlight. Open Columns to inspect the same colors in a portaled menu. Keyboard focus keeps its visible indicator.

+ +
+
+ ); +} + +/** Documentation composes real primitives; all swatches and samples use CSS tokens. */ +export function ColorsPreview({ section }: ColorsPreviewProps) { + return ( +
+
+
+

Design proposals · Foundations

+

Colors

+

Light mode uses the production palette. Dark mode uses the Paper proposal palette. Both share semantic tokens, so components, menus and conversation states follow the selected theme.

+ View the Paper color reference +
+ {section === "tokens" ? : section === "surfaces" ? : } +
+
+ ); +} diff --git a/ui/storybook/support/design-proposals/ConversationTypeSpec.tsx b/ui/storybook/support/design-proposals/ConversationTypeSpec.tsx new file mode 100644 index 00000000000..2fe6a252eb0 --- /dev/null +++ b/ui/storybook/support/design-proposals/ConversationTypeSpec.tsx @@ -0,0 +1,146 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import DesignSandboxTaskThread from "@/components/design-sandbox/DesignSandboxTaskThread"; +import { THREAD_PREVIEW_CASES } from "@/components/design-sandbox/task-thread-fixtures"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { NativeSelect } from "@/components/ui/select"; +import { storybookAgents } from "../../fixtures/paperclipData"; +import { measureConversationType, type ConversationTypeInventory, type ConversationTypeRole } from "./conversation-type-inventory"; + +const agent = { ...storybookAgents[0]!, name: "Connector engineer" }; +const empty: ConversationTypeInventory = { roles: [], states: [], capturedAt: "" }; +const familyName = (family: string) => family.includes("Paper Mono") ? "Paper Mono" : family.includes("Inter") ? "Inter Variable" : family; +function Metrics({ role }: { role: ConversationTypeRole }) { + return
+

{role.size} / {role.leading}

+

{familyName(role.family)} · weight {role.weight}

Tracking {role.tracking} · {role.emphasis}

+

{role.color} · opacity {role.opacity}

+
; +} + +export function ConversationTypeSpec() { + const root = useRef(null); + const [inventory, setInventory] = useState(empty); + const [query, setQuery] = useState(""); + const [area, setArea] = useState("All surfaces"); + const [sharedOnly, setSharedOnly] = useState(false); + const [expanding, setExpanding] = useState(false); + const [, setParams] = useSearchParams(); + const measure = () => { if (root.current) setInventory(measureConversationType(root.current)); }; + useLayoutEffect(() => { + let disposed = false; + let frame = 0; + void document.fonts.ready.then(() => { if (!disposed) frame = requestAnimationFrame(measure); }); + return () => { disposed = true; cancelAnimationFrame(frame); }; + }, []); + const groups = new Map(); + inventory.roles.forEach(role => groups.set(role.metricKey, [...(groups.get(role.metricKey) ?? []), role])); + const candidates = [...groups.values()].filter(roles => new Set(roles.map(role => role.area)).size > 1); + const candidateKeys = new Set(candidates.flatMap(roles => roles.map(role => role.key))); + const roles = inventory.roles.filter(role => (area === "All surfaces" || role.area === area) && (!sharedOnly || candidateKeys.has(role.key)) && `${role.name} ${role.text} ${role.sources.join(" ")} ${role.size} ${role.leading}`.toLowerCase().includes(query.toLowerCase())); + const sizes = [...new Set(inventory.roles.map(role => role.size))].sort((a, b) => parseFloat(b) - parseFloat(a)); + const families = [...new Set(inventory.roles.map(role => role.family))]; + const weights = [...new Set(inventory.roles.map(role => role.weight))].sort(); + const expandDetails = async () => { + setExpanding(true); + // Expand only read-only conversation disclosures. Never click decision, + // send, stop, feedback or other action controls. + const selectors = [ + 'button[data-testid="task-chat-turn-summary"][aria-expanded="false"]', + 'button[data-testid="task-chat-activity-phase-toggle"][aria-expanded="false"]', + '[data-activity-item-id] > button[aria-expanded="false"]', + '[data-testid="task-chat-thinking"] > button[aria-expanded="false"]', + '[data-testid="task-chat-tool-card"] > button[aria-expanded="false"]', + '[data-testid="task-chat-system-notice"] button[aria-expanded="false"]', + '[data-testid="task-chat-collapsible-marker"] button[aria-expanded="false"]', + ].join(","); + for (let depth = 0; depth < 6 && root.current; depth++) { + root.current.querySelectorAll("details:not([open])").forEach(detail => { detail.open = true; }); + const toggles = root.current.querySelectorAll(selectors); + if (!toggles.length) break; + toggles.forEach(button => button.click()); + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + measure(); + setExpanding(false); + }; + const inspect = (state: string) => { + setParams(params => { const next = new URLSearchParams(params); next.set("threadPreview", state); return next; }, { replace: true }); + root.current?.scrollIntoView({ block: "start" }); + }; + + return
+
+
+

Design proposals · Foundations · Typography

+

Conversation type spec

+

Inspect the consolidated typography in Conversation styles. This inventory measures text inside the sandbox’s actual conversation composition, across its {THREAD_PREVIEW_CASES.length} synthetic states. Specimens below copy those measured type properties; the interactive shared renderer remains available in context.

+ +

{inventory.states.length} states measured · {inventory.roles.length} role/style combinations · {groups.size} type recipes before color differences

+
+ +
+

Observed text sizes

Current browser values, not a proposed replacement ramp. One size can still have several line heights, weights or contrast treatments.

+
{sizes.map(size => { + const matches = inventory.roles.filter(role => role.size === size); + return
+

{size} · Aa

+

{[...new Set(matches.map(role => role.area))].join(" · ")}

{[...new Set(matches.map(role => role.leading))].join(" / ")} leading · {matches.length} role/style combinations

+

{matches[0]?.text}

+
; + })}
+
+ +
+

Families & weights

+
{families.map(family =>
+

{familyName(family)}

+

The agent checked ui/src/components/ and prepared a reply.

+ {family} +

{[...new Set(inventory.roles.filter(role => role.family === family).map(role => role.area))].join(" · ")}

+
)}
+
{weights.map(weight =>

Weight {weight} · conversation text

)}
+
+ +
+

Usage inventory

Each row is a surface plus a distinct measured type treatment. Compare samples, line height, weight, color and source recipes. Several rows may share metrics while keeping different semantic colors.

+
+ + + +
+

{roles.length} of {inventory.roles.length} role/style combinations · captured {inventory.capturedAt || "after fonts load"}

+
{roles.map(role =>
+

{role.area}

{role.name}

Seen in {role.states.length} states

+
Source & recipe{role.sources.map(source => {source})}

Text element and ancestor classes (nearest first):

{role.recipes.map((recipe, index) => {recipe})}
+
Example states
{role.states.map(state => )}
+
+

{role.text}

+
+
)}{roles.length === 0 ?

{inventory.roles.length ? "No roles match these filters." : "Measuring the shared conversation preview…"}

: null}
+
+ +
+

Consolidation candidates

These surfaces share family, size, leading, weight, tracking and italic treatment after consolidation. They may be candidates for a shared type role. Color, decoration and opacity stay separate review decisions; matching metrics alone do not mean two roles should merge.

+
{candidates.map(matches =>
+

{familyName(matches[0].family)} · {matches[0].size} / {matches[0].leading} · {matches[0].weight}

+

{[...new Set(matches.map(role => role.area))].join(" · ")}

+

{new Set(matches.map(role => `${role.color}:${role.opacity}`)).size} contrast treatments across {matches.length} role/style combinations

+
)}
+

Review the consolidated spec

  • Body text, authored code, commentary and reasoning now share 14px/20px. Review long replies and narrow layouts for readability.
  • Metadata, activity labels, diagnostic output and timestamps use 12px/16px. Review which distinctions still need separate roles.
  • Which color and opacity differences communicate useful hierarchy, status or contrast?
  • Keep code and paths in Paper Mono while deciding whether their sizes and rhythm can be shared.

The shared proposal conversation recipes are applied in Conversation styles and the opt-in sandbox. Future changes belong in those shared tokens/renderers; this inventory measures their current output.

+
+ +
+

In context · shared conversation

+

Starts with all {THREAD_PREVIEW_CASES.length} states. Use the real preview controls and expand disclosures to reveal more text, then refresh the inventory. “Expand details & measure” opens read-only activity, reasoning and notice disclosures for the selected states. Only currently rendered text is measured; closed disclosures, empty inputs, placeholders and absent Markdown structures are not counted. This is a fixture inventory, not every possible production style.

+

Refreshing replaces the inventory with the current preview selection.

+
+

Choose a synthetic state to inspect typography. Saved backend conversations are available in the sandbox.

+
+
+
+
; +} diff --git a/ui/storybook/support/design-proposals/ConversationTypographyPreview.tsx b/ui/storybook/support/design-proposals/ConversationTypographyPreview.tsx new file mode 100644 index 00000000000..56d29db6ccd --- /dev/null +++ b/ui/storybook/support/design-proposals/ConversationTypographyPreview.tsx @@ -0,0 +1,68 @@ +import { useRef, useState } from "react"; +import DesignSandboxTaskThread from "@/components/design-sandbox/DesignSandboxTaskThread"; +import { Button } from "@/components/ui/button"; +import { storybookAgents } from "../../fixtures/paperclipData"; + +const agent = { ...storybookAgents[0]!, name: "Connector engineer" }; +type ObservedStyle = { key: string; family: string; size: string; leading: string; weight: string; color: string; emphasis: string; tracking: string; opacity: string; example: string; text: string }; + +/** The sandbox owns the entire conversation composition, fixtures and controls. + * This guide only frames it and optionally measures its rendered typography. */ +export function ConversationTypographyPreview({ playback = false }: { playback?: boolean }) { + const root = useRef(null); + const [styles, setStyles] = useState([]); + const measure = () => { + const recipes = new Map(); + root.current?.querySelectorAll("[data-preview-case]").forEach(example => { + const walker = document.createTreeWalker(example, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode; + const parent = node.parentElement; + const text = node.textContent?.replace(/\s+/g, " ").trim(); + if (!text || !parent || parent.closest('[aria-hidden="true"], .sr-only, script, style') || !parent.getClientRects().length) continue; + const computed = getComputedStyle(parent); + if (computed.visibility === "hidden" || computed.display === "none" || computed.opacity === "0") continue; + const values = { + family: computed.fontFamily, size: computed.fontSize, leading: computed.lineHeight, + weight: computed.fontWeight, color: computed.color, + emphasis: `${computed.fontStyle} / ${computed.textDecorationLine}`, + tracking: computed.letterSpacing, opacity: computed.opacity, + }; + const key = JSON.stringify(values); + if (!recipes.has(key)) recipes.set(key, { key, ...values, example: example.getAttribute("aria-label") ?? "", text: text.slice(0, 90) }); + } + }); + setStyles([...recipes.values()].sort((a, b) => parseFloat(b.size) - parseFloat(a.size) || a.family.localeCompare(b.family))); + }; + + return
+
+

Design proposals · Foundations · Typography

+

{playback ? "Conversation playback" : "Conversation styles"}

+

{playback ? "Press Play to watch two exchanges: a component audit, then verification with planning, clarification, permission, a failed test and retry, a file preview, and streaming final replies. Demo answers and approvals advance automatically. Pause to inspect any moment, or replay from the beginning." : "The same 72 synthetic states, conversation composition and controls as the sandbox. Select a state to replay it, expand activity, or try its local composer actions."}

+ {!playback ?
{ if (event.currentTarget.open) measure(); }}> + Typography measurements +
+

Measures currently rendered text in the selected states, including open disclosures. Refresh after changing a state or expanding content. Guide labels are excluded.

+ +
+ + {["Text / state", "Family", "Size / leading", "Weight / emphasis", "Color / tracking / opacity"].map(label => )} + {styles.map(style => + + + + )} +
{label}

{style.text}

{style.example}

{style.family}{style.size} / {style.leading}{style.weight} · {style.emphasis}{style.color} / {style.tracking} / {style.opacity}
+
+

{styles.length} observed styles. Shared source: ui/src/components/design-sandbox/DesignSandboxTaskThread.tsx and task-thread-fixtures.ts. These use production renderers with synthetic data; this is not verification of a deployed production version.

+
+
: null} +
+
+ +

Saved task data is available in the sandbox. Choose a synthetic preview state here to compare conversation styles.

+
+
+
; +} diff --git a/ui/storybook/support/design-proposals/DesignProposalAccess.tsx b/ui/storybook/support/design-proposals/DesignProposalAccess.tsx new file mode 100644 index 00000000000..0f8549f1510 --- /dev/null +++ b/ui/storybook/support/design-proposals/DesignProposalAccess.tsx @@ -0,0 +1,27 @@ +import { useLayoutEffect, useState, type ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { CurrentBoardAccess } from "@/api/access"; +import type { HealthStatus } from "@/api/health"; +import { queryKeys } from "@/lib/queryKeys"; +import { storybookAuthSession, storybookCompanies } from "../../fixtures/paperclipData"; +import { installFixtureApi } from "../capability-gaps/api"; + +/** Supply the same owner access and loaded operator policy that production + * menus require. This preview never bypasses their invitation gates. */ +export function DesignProposalAccess({ children }: { children: ReactNode }) { + const client = useQueryClient(); + const [ready, setReady] = useState(false); + useLayoutEffect(() => { + const access: CurrentBoardAccess = { + user: storybookAuthSession.user, userId: "user-board", isInstanceAdmin: false, + companyIds: storybookCompanies.map(company => company.id), source: "authenticated", keyId: null, + memberships: storybookCompanies.map(company => ({ companyId: company.id, membershipRole: "owner", status: "active" })), + }; + const fixtures = installFixtureApi([{ path: "/api/cli-auth/me", read: () => access }]); + client.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "authenticated", hiddenSettings: [] }); + client.setQueryData(queryKeys.access.currentBoardAccess, access); + setReady(true); + return () => fixtures.cleanup(); + }, [client]); + return ready ? children : null; +} diff --git a/ui/storybook/support/design-proposals/FlyoutPreview.tsx b/ui/storybook/support/design-proposals/FlyoutPreview.tsx new file mode 100644 index 00000000000..67744158687 --- /dev/null +++ b/ui/storybook/support/design-proposals/FlyoutPreview.tsx @@ -0,0 +1,63 @@ +import { useState, type CSSProperties } from "react"; +import { Check } from "lucide-react"; +import { SidebarAccountMenu } from "@/components/SidebarAccountMenu"; +import { TruncatedTextMarqueeProvider } from "@/components/TruncatedText"; +import { DesignProposalAccess } from "./DesignProposalAccess"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Flyout, FlyoutAppearanceProvider, FlyoutContent, FlyoutHeader, FlyoutOptions, FlyoutTrigger } from "@/components/ui/flyout"; +import { allTaskListColumns, defaultTaskListColumns, taskListColumns, type TaskListColumnId } from "@/lib/task-list"; +import { taskListViews, type TaskListView } from "@/lib/task-list-views"; + +export type FlyoutPreviewProps = { mode: "columns" | "views" | "account-menu"; shadow?: string }; + +/** Isolated reference for the same composition used by the page's flyouts. + * Shadow overrides stay local to this preview; the saved default is a token. */ +export function FlyoutPreview({ mode, shadow }: FlyoutPreviewProps) { + const [columns, setColumns] = useState(defaultTaskListColumns); + const [view, setView] = useState("all"); + const [accountOpen, setAccountOpen] = useState(true); + return ( +
+
+

Popover

+

Shared title block, options, selection marks and shadow. Hover or focus an option to inspect its highlight.

+

The Shadow control previews a CSS box-shadow. Clear it to inherit the shared --shadow-flyout token.

+
+ {mode === "account-menu" ? ( + +
+
+ +
+
+
+ ) : mode === "views" ? ( + + + + Views + setView(value as TaskListView)}> + {taskListViews.map(({ value, label }) => {label})} + + + + ) : ( + + + + Columns + + <> + {allTaskListColumns.map(id => )} + + + + + + )} +
+
+
+ ); +} diff --git a/ui/storybook/support/design-proposals/ProjectHealthTrial.tsx b/ui/storybook/support/design-proposals/ProjectHealthTrial.tsx new file mode 100644 index 00000000000..5c3ba35e9e3 --- /dev/null +++ b/ui/storybook/support/design-proposals/ProjectHealthTrial.tsx @@ -0,0 +1,160 @@ +import { useRef, useState } from "react"; +import { CheckCheck, ChevronDown, CircleAlert, FolderOpen, ListChecks, Search, SearchX, X } from "lucide-react"; +import { CommandPalette } from "@/components/CommandPalette"; +import { DesignPageShell } from "@/components/DesignPageShell"; +import { DesignSidebar } from "@/components/DesignSidebar"; +import { EmptyState } from "@/components/EmptyState"; +import { MetricCard } from "@/components/MetricCard"; +import { NewIssueDialog } from "@/components/NewIssueDialog"; +import { TaskRowGroup } from "@/components/TaskRowGroup"; +import { TaskSummaryList } from "@/components/TaskSummaryList"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { FlyoutHeader, FlyoutOptions } from "@/components/ui/flyout"; +import { Input } from "@/components/ui/input"; +import { useCompany } from "@/context/CompanyContext"; +import { useDialogActions } from "@/context/DialogContext"; +import { matchesTaskListView } from "@/lib/task-list-views"; +import { DesignProposalAccess } from "./DesignProposalAccess"; +import { projectHealthProjects, projectHealthTasks } from "./project-health-trial-data"; + +type ProjectView = "all" | "attention"; +export type ProjectHealthTrialProps = { + initialQuery?: string; + initialView?: ProjectView; + initialProjectId?: string; + emptyOrganization?: boolean; +}; + +const projectViews = [ + { value: "all", label: "All projects" }, + { value: "attention", label: "Needs attention" }, +] as const; +const projectStatusLabels = { + backlog: "Backlog", planned: "Planned", in_progress: "In progress", completed: "Completed", cancelled: "Cancelled", +}; + +/** Trial-only project composition. Shared task semantics and UI remain unchanged. */ +function ProjectHealthContent({ initialQuery = "", initialView = "all", initialProjectId, emptyOrganization = false }: ProjectHealthTrialProps) { + const { selectedCompanyId } = useCompany(); + const { openNewIssue } = useDialogActions(); + const [query, setQuery] = useState(initialQuery); + const [view, setView] = useState(initialView); + const [selectedId, setSelectedId] = useState(initialProjectId ?? null); + const searchRef = useRef(null); + const projectButtons = useRef(new Map()); + + const projects = emptyOrganization ? [] : projectHealthProjects.filter(project => project.companyId === selectedCompanyId); + const tasks = projectHealthTasks.filter(task => task.companyId === selectedCompanyId && projects.some(project => project.id === task.projectId)); + const needsAttention = (task: typeof tasks[number]) => matchesTaskListView("blocked", task, false, null); + const projectsWithCounts = projects.map(project => { + const projectTasks = tasks.filter(task => task.projectId === project.id); + return { + ...project, + tasks: projectTasks.toSorted((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()), + total: projectTasks.length, + attention: projectTasks.filter(needsAttention).length, + completed: projectTasks.filter(task => task.status === "done").length, + }; + }); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const visibleProjects = projectsWithCounts.filter(project => + (view === "all" || project.attention > 0) + && `${project.name} ${project.description ?? ""}`.toLocaleLowerCase().includes(normalizedQuery), + ); + // A filtered-out project must never leave unrelated tasks visible in the pane. + const selectedProject = visibleProjects.find(project => project.id === selectedId); + const clear = () => { setQuery(""); setView("all"); setSelectedId(null); searchRef.current?.focus(); }; + const closeSelection = () => { + if (selectedId) projectButtons.current.get(selectedId)?.focus(); + setSelectedId(null); + }; + + return <> + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()).slice(0, 5)} onNewTask={() => openNewIssue()} />} + rightSidebarTitle="Recent tasks" + rightSidebarControls={selectedProject && } + rightSidebar={selectedProject ? <> +
+

{selectedProject.name}

+

{selectedProject.description}

+ {projectStatusLabels[selectedProject.status]} +
+ {selectedProject.tasks.length ?
+

Most recently updated · {Math.min(6, selectedProject.total)} of {selectedProject.total} tasks

+ +
: } + : } + > +
+
+ project.status === "in_progress").length} label="Active projects" description="Projects in progress" /> + + task.status === "done").length} label="Completed tasks" description="Across all projects" /> +
+
+
+ + + + Views + { setView(value as ProjectView); setSelectedId(null); }} asChild> + + {projectViews.map(option => {option.label})} + + + + +
+ + { setQuery(event.target.value); setSelectedId(null); }} /> + {query && } +
+
+

{visibleProjects.length} of {projects.length} projects{view === "attention" ? " · With blocked or in-review tasks" : " · Select a project to inspect recent work"}

+ {projects.length === 0 ? + : visibleProjects.length === 0 ? + :
    + {visibleProjects.map(project =>
  • + + + +
  • )} +
} +
+
+
+ + + ; +} + +export function ProjectHealthTrial(props: ProjectHealthTrialProps) { + const { selectedCompanyId } = useCompany(); + return ; +} diff --git a/ui/storybook/support/design-proposals/SegmentedControllersPreview.tsx b/ui/storybook/support/design-proposals/SegmentedControllersPreview.tsx new file mode 100644 index 00000000000..3a84fb0a334 --- /dev/null +++ b/ui/storybook/support/design-proposals/SegmentedControllersPreview.tsx @@ -0,0 +1,70 @@ +import { useState, type ReactNode } from "react"; +import { Columns3, List, Network, SquareKanban } from "lucide-react"; +import { FilePreviewModeToggle, type FilePreviewMode } from "@/components/FilePreviewModeToggle"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +export interface SegmentedControllersPreviewProps { + width: "full" | "narrow"; + disableAgentActions: boolean; + onWidthChange?: (width: SegmentedControllersPreviewProps["width"]) => void; +} +function Specimen({ title, source, children }: { title: string; source: string; children: ReactNode }) { + return
+

{title}

{source}

+
{children}
+
; +} +function PanelSwitch({ label, items, grid = false, disabled = false }: { label: string; items: string[]; grid?: boolean; disabled?: boolean }) { + const [value, setValue] = useState(items[0]); + return + + {items.map((item, index) => {item})} + + {items.map(item =>

Selected panel: {item}. Fixture content only.

)} +
; +} +function LayoutSwitch({ kind }: { kind: "proposal" | "issues" | "agents" }) { + const [selected, setSelected] = useState(0); + const items = kind === "agents" ? [{ label: "List view", icon: List }, { label: "Org chart view", icon: Network }] : [{ label: "List view", icon: List }, { label: "Board view", icon: kind === "proposal" ? Columns3 : SquareKanban }]; + return
+ {items.map(({ label, icon: Icon }, index) => kind === "issues" ? : )} +
; +} +function InstructionSwitch() { + const [mode, setMode] = useState("read"); + return
+ {["read", "edit", "raw"].map(value => )} +
; +} +function FileSwitch() { + const [mode, setMode] = useState("rendered"); + return ; +} +function ModelSwitch() { + const [lane, setLane] = useState("primary"); + return
+ {["primary", "custom"].map(value => )} +
; +} +export function SegmentedControllersPreview({ width, disableAgentActions, onWidthChange }: SegmentedControllersPreviewProps) { + return
+

Design proposals · Components

Segmented controllers

+

Compare current connected choices: panel tabs, layout switches, file modes and model lanes. Each example keeps its existing styling and semantics; interactions affect local fixture state only.

+ +
+
+ +
+ + + + + + + + +
+

Inline page controls are isolated compositions of their production primitives, not full production-page mounts. These examples establish a comparison surface, not backend or accessibility acceptance. No existing app controls have been restyled.

+
; +} diff --git a/ui/storybook/support/design-proposals/SidebarPreview.tsx b/ui/storybook/support/design-proposals/SidebarPreview.tsx new file mode 100644 index 00000000000..d815ed317e7 --- /dev/null +++ b/ui/storybook/support/design-proposals/SidebarPreview.tsx @@ -0,0 +1,46 @@ +import { CommandPalette } from "@/components/CommandPalette"; +import { DesignPageShell } from "@/components/DesignPageShell"; +import { DesignSidebar, type DesignSidebarRecentTask } from "@/components/DesignSidebar"; +import { NewIssueDialog } from "@/components/NewIssueDialog"; +import { useDialogActions } from "@/context/DialogContext"; +import { DesignProposalAccess } from "./DesignProposalAccess"; +import { recentTasks } from "./task-page-data"; + +export type SidebarPreviewProps = { + scenario: "default" | "narrow" | "long-labels" | "many-recents" | "empty-recents" | "organization-menu" | "account-menu"; +}; + +const defaultRecents = recentTasks.map(item => item.issue); +const longRecents = defaultRecents.map((task, index) => ({ + ...task, + title: index === 0 ? "Review the connector recovery experience across organizations and shared workspaces" : task.title, +})); +const manyRecents: DesignSidebarRecentTask[] = Array.from({ length: 30 }, (_, index) => ({ + ...defaultRecents[index % defaultRecents.length]!, + id: `sidebar-recent-${index}`, + identifier: `PAP-${500 + index}`, +})); + +function SidebarContent({ scenario }: SidebarPreviewProps) { + const { openNewIssue } = useDialogActions(); + const tasks = scenario === "empty-recents" ? [] : scenario === "many-recents" ? manyRecents : scenario === "long-labels" ? longRecents : defaultRecents; + return <> + openNewIssue()} />} + > +
+

Sidebar component

+

This is the same sidebar used by Pages → Tasks. Resize its right edge, hover truncated labels, or use the organization, account and global-search controls.

+

Navigation uses a fixture router. New Task and global search mount the existing dialogs; this preview does not establish backend behavior.

+
+
+ + + ; +} + +export function SidebarPreview(props: SidebarPreviewProps) { + return ; +} diff --git a/ui/storybook/support/design-proposals/TabsPreview.tsx b/ui/storybook/support/design-proposals/TabsPreview.tsx new file mode 100644 index 00000000000..b0e0a306c39 --- /dev/null +++ b/ui/storybook/support/design-proposals/TabsPreview.tsx @@ -0,0 +1,56 @@ +import { useState, type ReactNode } from "react"; +import { TabBar, TabFilters, type TabSize } from "@/components/TabBar"; +import { ArtifactKindFilters } from "@/components/artifacts/ArtifactsToolbar"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; +import type { ArtifactKindFilter } from "@/api/artifacts"; + +export interface TabsPreviewProps { + pattern: "all" | "standard" | "compact"; + width: "full" | "narrow"; + onWidthChange?: (width: TabsPreviewProps["width"]) => void; +} +function Specimen({ title, description, children }: { title: string; description: string; children: ReactNode }) { + return
+

{title}

{description}

+ {children} +
; +} + +function ArtifactFilters() { + const [kind, setKind] = useState("all"); + return <>Artifact type: {kind}; +} + +function CanonicalTabs({ size }: { size: TabSize }) { + const [value, setValue] = useState("paperclip"); + const items = [{ value: "paperclip", label: "Paperclip" }, { value: "composio", label: "Composio" }, { value: "arcade", label: "Arcade" }, { value: "installed", label: "Installed" }]; + return + + {items.map(item =>

Selected panel: {item.label}. Fixture content only.

)} +
; +} +function DisabledTabs() { + const [value, setValue] = useState("available"); + return ; +} + +export function TabsPreview({ pattern, width, onWidthChange }: TabsPreviewProps) { + return
+
+

Design proposals · Components

Tabs

+

Two canonical sizes with one filled selection treatment. Compact uses 12px type; Standard uses 14px type and the Paper reference geometry. Content tabs switch panels; collection filters use radio choices. Both support keyboard navigation.

+ +
+
+ {(pattern === "all" || pattern === "standard") && } + {(pattern === "all" || pattern === "compact") && } + +
+ +
; +} diff --git a/ui/storybook/support/design-proposals/TaskHoverCardPreview.tsx b/ui/storybook/support/design-proposals/TaskHoverCardPreview.tsx new file mode 100644 index 00000000000..1d95270c6c7 --- /dev/null +++ b/ui/storybook/support/design-proposals/TaskHoverCardPreview.tsx @@ -0,0 +1,66 @@ +import { useLayoutEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ActivityRow } from "@/components/ActivityRow"; +import { IssueQuicklookCard } from "@/components/IssueLinkQuicklook"; +import { TaskSummaryList } from "@/components/TaskSummaryList"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { FlyoutAppearanceProvider } from "@/components/ui/flyout"; +import { Link } from "@/lib/router"; +import { queryKeys } from "@/lib/queryKeys"; +import { createIssue, storybookActivityEvents, storybookAgentMap, storybookProjects } from "../../fixtures/paperclipData"; + +const task = createIssue({ + id: "design-task-hover-card", identifier: "PAP-173", status: "done", + title: "Artifact Review: Finish the remaining approved wireframe gaps", + description: "Followup on PAP-717 (wireframes approved Oct 13, card f47824d1). Same worktree/branch as PAP-717; build on commits 11410df47 through 61c81e105. Review the remaining wireframe gaps with the team.", + project: { ...storybookProjects[0]!, name: "Design system" }, + updatedAt: new Date("2026-10-14T17:00:00Z"), +}); + +export function TaskHoverCardPreview({ enabled = true, showDescription = true, onEnabledChange }: { + enabled?: boolean; + showDescription?: boolean; + onEnabledChange?: (enabled: boolean) => void; +}) { + const client = useQueryClient(); + const issue = { ...task, description: showDescription ? task.description : null }; + useLayoutEffect(() => { + const snapshot = { ...task, description: showDescription ? task.description : null }; + for (const ref of [task.id, task.identifier!]) client.setQueryData(queryKeys.issues.detail(ref), snapshot); + }, [client, showDescription]); + return +
+
+
+

Paperclip · Design system · Proposed

+

Task hover card

+

A contextual task preview for links and activity. Summary rows use their own local text reveal and keep this preview off.

+
+
+

Appearance

+
+ +
+
+
+
+

Text link

+ +
+

Related work: PAP-173

+
+
+

Recent activity · Preview on

+ + + +
+
+

Recent tasks · Preview off

+ +
+
+
+
; +} diff --git a/ui/storybook/support/design-proposals/TaskListPagePreview.tsx b/ui/storybook/support/design-proposals/TaskListPagePreview.tsx new file mode 100644 index 00000000000..549c69ccb45 --- /dev/null +++ b/ui/storybook/support/design-proposals/TaskListPagePreview.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { ListChecks, SearchX } from "lucide-react"; +import { CommandPalette } from "@/components/CommandPalette"; +import { DesignSidebar } from "@/components/DesignSidebar"; +import { DesignProposalAccess } from "./DesignProposalAccess"; +import { initialItems, recentTasks } from "./task-page-data"; +import { DesignPageShell } from "@/components/DesignPageShell"; +import { EmptyState } from "@/components/EmptyState"; +import { KanbanBoard } from "@/components/KanbanBoard"; +import { NewIssueDialog } from "@/components/NewIssueDialog"; +import { TaskList } from "@/components/TaskList"; +import { TaskListToolbar } from "@/components/TaskListToolbar"; +import { TruncatedTextMarqueeProvider } from "@/components/TruncatedText"; +import { useCompany } from "@/context/CompanyContext"; +import { useDialogActions } from "@/context/DialogContext"; +import { useTaskListCollection } from "@/hooks/useTaskListCollection"; +import { allTaskListColumns, defaultTaskListColumns } from "@/lib/task-list"; +import { taskListViewStorageKey } from "@/lib/task-list-views"; +import { storybookAgents, storybookAuthSession, storybookIssueLabels, storybookProjects } from "../../fixtures/paperclipData"; + +const currentUserId = storybookAuthSession.user.id; +const viewStorageKey = taskListViewStorageKey("design-proposals", currentUserId); + +type PreviewProps = { allColumns?: boolean; initialQuery?: string }; + +export function TaskListPagePreview(props: PreviewProps) { + return ; +} + +/** Fixture controller only; the page consumes shared production components. */ +function TaskListPageContent({ allColumns = false, initialQuery = "" }: PreviewProps) { + const [items, setItems] = useState(initialItems); + const { selectedCompanyId } = useCompany(); + const { openNewIssue } = useDialogActions(); + const { query, setQuery, view, setView, layout, setLayout, filters, updateFilters, clear, + companyItems, matchingItems, visibleItems, creators, collapsedIds, setCollapsedIds, + allExpanded, toggleExpanded, activeFilterCount, narrowed } = useTaskListCollection({ + items, companyId: selectedCompanyId, userId: currentUserId, viewStorageKey, initialQuery, + }); + const emptyState = companyItems.length === 0 + ? + : ; + return ( + + item.issue)} onNewTask={() => openNewIssue()} />}> + openNewIssue()} + filters={{ state: filters, onChange: updateFilters, activeFilterCount, agents: storybookAgents, projects: storybookProjects, labels: storybookIssueLabels, currentUserId, creators }} + /> + {narrowed ?

{matchingItems.length} matching {matchingItems.length === 1 ? "task" : "tasks"}{visibleItems.length > matchingItems.length ? " · Parent tasks included for context" : ""}

: null} + + {layout === "board" ?
{matchingItems.length ? item.issue)} agents={storybookAgents} onUpdateIssue={(id, data) => setItems(current => current.map(item => item.issue.id === id ? { ...item, issue: { ...item.issue, ...data } } : item))} /> : emptyState}
: null} +
+ + +
+ ); +} diff --git a/ui/storybook/support/design-proposals/TasksReproductionTrial.tsx b/ui/storybook/support/design-proposals/TasksReproductionTrial.tsx new file mode 100644 index 00000000000..1e89fc2952a --- /dev/null +++ b/ui/storybook/support/design-proposals/TasksReproductionTrial.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { ListChecks, SearchX } from "lucide-react"; +import { CommandPalette } from "@/components/CommandPalette"; +import { DesignPageShell } from "@/components/DesignPageShell"; +import { DesignSidebar } from "@/components/DesignSidebar"; +import { EmptyState } from "@/components/EmptyState"; +import { KanbanBoard } from "@/components/KanbanBoard"; +import { NewIssueDialog } from "@/components/NewIssueDialog"; +import { TaskList, type TaskListItem } from "@/components/TaskList"; +import { TaskListToolbar } from "@/components/TaskListToolbar"; +import { useCompany } from "@/context/CompanyContext"; +import { useDialogActions } from "@/context/DialogContext"; +import { useTaskListCollection } from "@/hooks/useTaskListCollection"; +import { TruncatedTextMarqueeProvider } from "@/components/TruncatedText"; +import { taskListViewStorageKey } from "@/lib/task-list-views"; +import { storybookAgents, storybookIssueLabels, storybookProjects } from "../../fixtures/paperclipData"; +import { DesignProposalAccess } from "./DesignProposalAccess"; +import { initialItems, recentTasks } from "./task-page-data"; + +// Controller only: all visual composition comes from the shared proposal system. +const userId = "user-board"; +const viewKey = taskListViewStorageKey("design-agent-task-list-trial", userId); + +function TrialContent() { + const { selectedCompanyId } = useCompany(); + const { openNewIssue } = useDialogActions(); + const [items, setItems] = useState(initialItems); + const { query, setQuery, view, setView, layout, setLayout, filters, updateFilters, clear, + companyItems, matchingItems: matches, visibleItems, creators, collapsedIds, setCollapsedIds, + allExpanded, toggleExpanded, activeFilterCount, narrowed } = useTaskListCollection({ + items, companyId: selectedCompanyId, userId, viewStorageKey: viewKey, + }); + const emptyState = companyItems.length === 0 + ? + : ; + + return + item.issue)} onNewTask={() => openNewIssue()} />}> + openNewIssue()} + filters={{ state: filters, onChange: updateFilters, activeFilterCount, + agents: storybookAgents, projects: storybookProjects, labels: storybookIssueLabels, currentUserId: userId, + creators, + }} /> + {narrowed ?

{matches.length} matching task{matches.length === 1 ? "" : "s"}{visibleItems.length > matches.length ? " · Parent tasks included for context" : ""}

: null} + + {layout === "board" ?
+ {matches.length ? item.issue)} agents={storybookAgents} showOverflowFades + onUpdateIssue={(id, patch) => setItems(current => current.map(item => item.issue.id === id ? { ...item, issue: { ...item.issue, ...patch } } : item))} /> : emptyState} +
: null} +
+ + +
; +} + +export function TasksReproductionTrial() { + return ; +} diff --git a/ui/storybook/support/design-proposals/TasksReproductionTrial2.tsx b/ui/storybook/support/design-proposals/TasksReproductionTrial2.tsx new file mode 100644 index 00000000000..320cc575463 --- /dev/null +++ b/ui/storybook/support/design-proposals/TasksReproductionTrial2.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { ListTodo } from "lucide-react"; +import { CommandPalette } from "@/components/CommandPalette"; +import { DesignPageShell } from "@/components/DesignPageShell"; +import { DesignSidebar } from "@/components/DesignSidebar"; +import { EmptyState } from "@/components/EmptyState"; +import { KanbanBoard } from "@/components/KanbanBoard"; +import { NewIssueDialog } from "@/components/NewIssueDialog"; +import { TaskList } from "@/components/TaskList"; +import { TaskListToolbar } from "@/components/TaskListToolbar"; +import { useCompany } from "@/context/CompanyContext"; +import { useDialogActions } from "@/context/DialogContext"; +import { useTaskListCollection } from "@/hooks/useTaskListCollection"; +import { taskListViewStorageKey } from "@/lib/task-list-views"; +import { storybookAgents, storybookAuthSession, storybookIssueLabels, storybookProjects } from "../../fixtures/paperclipData"; +import { DesignProposalAccess } from "./DesignProposalAccess"; +import { initialItems, recentTasks } from "./task-page-data"; + +const scope = "design-agent-task-list-trial-2"; +const sidebarRecents = recentTasks.map(item => item.issue); + +function TrialContent() { + const [items, setItems] = useState(initialItems); + const { selectedCompanyId } = useCompany(); + const { openNewIssue } = useDialogActions(); + const userId = storybookAuthSession.user.id; + const collection = useTaskListCollection({ + items, companyId: selectedCompanyId, userId, + viewStorageKey: taskListViewStorageKey(scope, userId), + }); + const newTask = () => openNewIssue(); + const emptyState = ; + + return <> + }> + + {collection.narrowed ?

+ {collection.matchingItems.length} matching {collection.matchingItems.length === 1 ? "task" : "tasks"} + {collection.layout === "list" && collection.visibleItems.length > collection.matchingItems.length ? " · Parent tasks included for context" : ""} +

: null} + {/* Preserve the shared table's column/sort state while the board is active. */} + + {collection.layout === "board" ?
+ {collection.matchingItems.length ? item.issue)} agents={storybookAgents} showOverflowFades + onUpdateIssue={(id, patch) => setItems(current => current.map(item => item.issue.id === id + ? { ...item, issue: { ...item.issue, ...patch } } : item))} + /> : emptyState} +
: null} +
+ + + ; +} + +/** Independent composition; collection transitions remain owned by the shared hook. */ +export function TasksReproductionTrial2() { + return ; +} diff --git a/ui/storybook/support/design-proposals/ToolbarsPreview.tsx b/ui/storybook/support/design-proposals/ToolbarsPreview.tsx new file mode 100644 index 00000000000..65befde48a8 --- /dev/null +++ b/ui/storybook/support/design-proposals/ToolbarsPreview.tsx @@ -0,0 +1,148 @@ +import { SkillsNavigationTabs } from "@/components/skill-studio/SkillsNavigationTabs"; +import { RoutinesToolbar, type RoutineSortField, type RoutineSortDir, type RoutineGroupBy } from "@/components/RoutinesToolbar"; +import type { AttentionItem } from "@paperclipai/shared"; +import { useState, type ReactNode } from "react"; +import { DecisionsToolbar } from "@/components/DecisionsToolbar"; +import { ProjectsToolbar, type ProjectSortField, type ProjectSortDir } from "@/components/ProjectsToolbar"; +import { TaskListToolbar } from "@/components/TaskListToolbar"; +import { SkillsToolbar, type DiscoverySort } from "@/components/skill-studio/SkillsToolbar"; +import { ArtifactsToolbar } from "@/components/artifacts/ArtifactsToolbar"; +import type { ArtifactGroupBy, ArtifactKindFilter } from "@/api/artifacts"; +import { Button } from "@/components/ui/button"; +import { FlyoutAppearanceProvider } from "@/components/ui/flyout"; +import { useCompany } from "@/context/CompanyContext"; +import { useTaskListCollection } from "@/hooks/useTaskListCollection"; +import { defaultAttentionFilterState, countActiveAttentionFilters, filterAttentionItems, type AttentionFilterOptions, type AttentionGroupBy, type AttentionSortOrder } from "@/lib/attention"; +import { storybookAgents, storybookAuthSession, storybookIssueLabels, storybookProjects } from "../../fixtures/paperclipData"; +import { DesignProposalAccess } from "./DesignProposalAccess"; +import { initialItems } from "./task-page-data"; + +export type ToolbarsPreviewProps = { + family: "all" | "tasks" | "decisions" | "skills" | "artifacts" | "projects" | "routines"; + width: "full" | "medium" | "narrow"; + onWidthChange?: (width: ToolbarsPreviewProps["width"]) => void; +}; +const widths = { full: "max-w-none", medium: "max-w-3xl", narrow: "max-w-sm" }; +const attentionOptions: AttentionFilterOptions = { + sourceKinds: ["approval", "review", "failed_run"], severities: ["critical", "high", "low"], + projects: [], workspaces: [], hasNoProject: true, hasNoWorkspace: true, +}; + +const decisionItems = [ + { id: "review", sourceKind: "review" as const, severity: "high" as const, subject: { title: "Review connector recovery", identifier: "DES-3" }, whyNow: "Verification complete", originAgentName: "Connector engineer" }, + { id: "approval", sourceKind: "approval" as const, severity: "critical" as const, subject: { title: "Approve workspace access", identifier: "DES-8" }, whyNow: "Agent needs approval", originAgentName: "Design reviewer" }, +].map((item): AttentionItem => ({ + ...item, + companyId: "storybook-company", subject: { ...item.subject, kind: "issue", id: item.id, companyId: "storybook-company", status: "in_review", href: null }, + decisionVerbs: [], inlineResolvable: false, entryRule: "", exitRule: "", dedupKey: item.id, dismissalKey: item.id, + dismissal: null, rank: 0, activityAt: "2026-10-16T12:00:00Z", createdAt: "2026-10-16T12:00:00Z", updatedAt: "2026-10-16T12:00:00Z", + project: null, workspace: null, relatedIssue: null, detail: null, expiresAt: null, ruleKey: null, queues: [], shelf: false, + retentionDays: 30, keep: false, archivedAt: null, retentionVersion: 1, decideBy: null, decideByAttribution: null, snoozedUntil: null, trainingExampleId: null, +})); + +function Specimen({ title, source, children }: { title: string; source: string; children: ReactNode }) { + return
+

{title}

{source}

+ {children} +
; +} + +function TaskToolbarSpecimen() { + const { selectedCompanyId } = useCompany(); + const collection = useTaskListCollection({ items: initialItems, companyId: selectedCompanyId, userId: storybookAuthSession.user.id, viewStorageKey: "paperclip:design-proposals:toolbars:task-view" }); + const [action, setAction] = useState(""); + return + setAction("New Task requested · local preview only")} + filters={{ state: collection.filters, onChange: collection.updateFilters, activeFilterCount: collection.activeFilterCount, agents: storybookAgents, projects: storybookProjects, labels: storybookIssueLabels, currentUserId: storybookAuthSession.user.id, creators: collection.creators }} /> + {collection.matchingItems.length} matching tasks · {collection.layout === "list" ? "List" : "Board"} · {collection.allExpanded ? "Expanded" : "Collapsed"}{action ? ` · ${action}` : ""} + ; +} + +function DecisionsToolbarSpecimen() { + const [query, setQuery] = useState(""); + const [filters, setFilters] = useState(defaultAttentionFilterState); + const [groupBy, setGroupBy] = useState("none"); + const [sortOrder, setSortOrder] = useState("newest"); + return + + {countActiveAttentionFilters(filters)} active filters · Group: {groupBy} · Sort: {sortOrder} · Search: {query || "All decisions"} + ; +} + +function SkillsToolbarSpecimen() { + const [search, setSearch] = useState(""); + const [sort, setSort] = useState("agents"); + const [source, setSource] = useState("all"); + const [scanPending, setScanPending] = useState(false); + const [selectMode, setSelectMode] = useState(false); + const [action, setAction] = useState(""); + const report = (label: string) => () => setAction(`${label} requested · local preview only`); + return + } viewTitle="Installed skills" viewDescription="Skills available to this organization." searchLabel="Search installed skills" + search={search} onSearchChange={setSearch} sort={sort} onSortChange={setSort} + availableSources={[{ value: "github", label: "GitHub" }, { value: "local", label: "Local" }]} sourceBadgeFilter={source} setSourceBadgeFilter={setSource} + onScan={() => { setScanPending(true); setAction("Scanning project workspaces · local preview only"); }} scanPending={scanPending} + onCreate={report("Create skill")} onBrowseDiscover={report("Discover skills")} onImport={report("Import skill")} onImportFromProject={report("Import from project")} + onCreateFolder={report("New folder")} selectMode={selectMode} onToggleSelectMode={() => setSelectMode(value => !value)} /> + {scanPending && } + Search: {search || "All skills"} · Sort: {sort} · Source: {source} · {selectMode ? "Selecting" : "Browsing"}{action ? ` · ${action}` : ""} + ; +} + +function ArtifactsToolbarSpecimen() { + const [query, setQuery] = useState(""); + const [groupBy, setGroupBy] = useState("task"); + const [kind, setKind] = useState("all"); + return + + Search: {query || "All artifacts"} · Group: {groupBy} · Type: {kind} + ; +} + +function ProjectsToolbarSpecimen() { + const [query, setQuery] = useState(""); + const [field, setField] = useState("name"); + const [direction, setDirection] = useState("asc"); + const [action, setAction] = useState(""); + return + { setField(nextField); setDirection(nextDirection); }} onNewProject={() => setAction("Add Project requested · local preview only")} /> + Search: {query || "All projects"} · Sort: {field} · Direction: {direction}{action ? ` · ${action}` : ""} + ; +} + +function RoutinesToolbarSpecimen() { + const [query, setQuery] = useState(""); + const [field, setField] = useState("updated"); + const [direction, setDirection] = useState("desc"); + const [group, setGroup] = useState("folder"); + const [action, setAction] = useState(""); + return + { setField(nextField); setDirection(nextDirection); }} onGroupChange={setGroup} + runsHref="/activity/runs" onNewFolder={group === "folder" ? () => setAction("New folder requested · local preview only") : undefined} + onCreateRoutine={() => setAction("Create routine requested · local preview only")} /> + Search: {query || "All routines"} · Sort: {field} · Direction: {direction} · Group: {group}{action ? ` · ${action}` : ""} + ; +} + +export function ToolbarsPreview({ family, width, onWidthChange }: ToolbarsPreviewProps) { + return +
+

Design proposals · Components

Toolbars

+

Canonical toolbar references for Design Proposals. Reuse the component for its job; task views, decision grouping, skill discovery and artifact browsing remain distinct patterns. Shared foundations, buttons, focus states and flyouts apply to each.

+ +
+
+ {(family === "all" || family === "tasks") && } + {(family === "all" || family === "decisions") && } + {(family === "all" || family === "skills") && } + {(family === "all" || family === "artifacts") && } + {(family === "all" || family === "projects") && } + {(family === "all" || family === "routines") && } +
+

Local fixture state only. Search, menus, toggles and feedback can be inspected here; creation actions report an intent without changing saved data. These are canonical proposal references, with production adoption and backend verification tracked separately.

+
+
; +} diff --git a/ui/storybook/support/design-proposals/TypographyPreview.tsx b/ui/storybook/support/design-proposals/TypographyPreview.tsx new file mode 100644 index 00000000000..ed297c1ebe9 --- /dev/null +++ b/ui/storybook/support/design-proposals/TypographyPreview.tsx @@ -0,0 +1,163 @@ +import { useLayoutEffect, useRef, useState, type RefObject } from "react"; +import { Bot } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { MetricCard } from "@/components/MetricCard"; +import { TaskList } from "@/components/TaskList"; +import { readTask, unreadTask } from "./task-list-data"; +import { typeRamp, typeRoles } from "./typography-specification"; + +type Metrics = { size: string; leading: string; weight: string; family: string; tracking: string }; +const proseRoles = [ + ["p", "Body paragraph"], ["h1", "Markdown H1"], ["h2", "Markdown H2"], ["h3", "Markdown H3"], ["h4", "Markdown H4"], + ["strong", "Strong emphasis"], ["em", "Italic emphasis"], ["a", "Text link"], ["li", "List item"], ["blockquote p", "Quoted text"], ["li code", "Inline code"], ["pre code", "Code block"], +] as const; + +function useTypeMetrics(root: RefObject, dependency: string) { + const [metrics, setMetrics] = useState>({}); + useLayoutEffect(() => { + let disposed = false; + const measure = () => { + if (disposed || !root.current) return; + const measured: Record = {}; + const read = (element: Element) => { + const style = getComputedStyle(element); + return { size: style.fontSize, leading: style.lineHeight, weight: style.fontWeight, family: style.fontFamily, tracking: style.letterSpacing }; + }; + root.current.querySelectorAll("[data-type-sample]").forEach(element => { + measured[element.dataset.typeSample!] = read(element); + }); + proseRoles.forEach(([selector, label]) => { + const element = root.current?.querySelector(`[data-markdown-specimen] .paperclip-markdown ${selector}`); + if (element) measured[label] = read(element); + }); + setMetrics(measured); + }; + measure(); + void document.fonts.ready.then(measure); + window.addEventListener("resize", measure); + return () => { disposed = true; window.removeEventListener("resize", measure); }; + }, [root, dependency]); + return metrics; +} + +function Measurement({ value, inherited = false }: { value?: Metrics; inherited?: boolean }) { + return + {value ? <>{value.size} / {value.leading}
Weight {value.weight}{inherited ? " · leading inherited in this sample" : ""}{value.tracking !== "normal" && value.tracking !== "0px" ? <>
Tracking {value.tracking} : null} : "Measuring…"} +
; +} + +const markdown = `# First-level heading +## Second-level heading +### Third-level heading +#### Fourth-level heading +This is rendered agent prose with **strong emphasis**, *italic emphasis*, and a [text link](#usage). + +- A list uses the same body rhythm. +- Inline code: \`pnpm typecheck\`. + +> A quoted note keeps the prose hierarchy. + +\`\`\`ts +const status = "in_progress"; +\`\`\``; + +/** A guide to current recipes. Integrated examples below mount shared components. */ +export function TypographyPreview() { + const root = useRef(null); + const [query, setQuery] = useState(""); + const [area, setArea] = useState("All areas"); + const [reviewOnly, setReviewOnly] = useState(false); + const roles = typeRoles.filter(role => (area === "All areas" || area === role.area) && (!reviewOnly || role.review) && `${role.name} ${role.use} ${role.recipe} ${role.source} ${role.review ?? ""}`.toLowerCase().includes(query.toLowerCase())); + const metrics = useTypeMetrics(root, roles.map(role => role.id).join(",")); + const areas = [...new Set(typeRoles.map(role => role.area))]; + + return
+
+
+

Design proposals · Foundations · Typography

+

Type specification

+

A proposed size ramp, with the text roles used by Design Proposals and the design sandbox. Samples read the active tokens. The usage inventory records current source recipes; differences from the proposed guidance remain visible for review.

+ +
+ +
+

Text size ramp

Your proposed 10–30px scale. Size and default leading below are measured in this browser; custom compact and nano sizes inherit their surrounding line height. Weights and role-specific leading are specified separately.

+
+ {typeRamp.map(step =>
+
{step.token} · {step.size}px--text-{step.token}
+

{step.use}

{step.source}

+
+
)} +
+

The number beside each step is the proposed default pixel size at a 16px root. The measured value follows the active token and browser settings. Rendered Markdown currently contains additional sizes; see In context.

+
+ +
+

Families & weights

+
+ {[ + { id: "sans", name: "Inter Variable", token: "--font-sans", className: "font-sans", sample: "Good UI starts with clear type.", use: "Interface labels, headings, prose and controls. Regular and italic variable font assets are bundled." }, + { id: "paper", name: "Paper Mono", token: "--font-paper-mono", className: "font-(family-name:--font-paper-mono)", sample: "DES-431 · codex/design-system", use: "The single monospace family for task IDs, worktrees, code, run metrics and diagnostics. The shared --font-mono token resolves to this same bundled font." }, + ].map(family =>
+

{family.name}

{family.token}
+

{family.sample}

+

{family.use}

+
Active font stack{metrics[`family-${family.id}`]?.family ?? "Measuring…"}
+
)} +
+
+ {[ + ["normal", "font-normal", "Read content / metadata / controls"], ["semibold", "font-semibold", "Headings / unread titles / organization trigger"], + ].map(([name, className, use]) =>

{name} · {metrics[`weight-${name}`]?.weight ?? "…"}

{use}

--font-weight-{name}
)} +
+
+

Color & emphasis

Default text uses --muted-foreground (Paper: dark-color-muted-foreground). Headings, emphasis and ON/selected states use --foreground. Hover promotes read titles without changing weight. Unread titles are semibold. Status, validation and text on a filled CTA/message surface use their semantic contrast colors.

+

Rhythm & overflow

Leading belongs to the role: task rows use 14/16, toolbar controls 14/20, navigation 14/21 and the task title 24/36. Density changes row height, not font size. Narrow lists hide metadata columns; they preserve readable title size, truncation and the shared marquee. Reduced motion keeps text readable without animation.

+
+
+ +
+

Usage inventory

Source-derived roles across the proposal components and sandbox pages. These isolated recipe samples document typography; they are not replacement components. Expand a source to find the owning implementation. Actual shared components appear below.

+
+ + + +
+

{roles.length} of {typeRoles.length} text roles

+
+ {roles.map(role =>
+

{role.area}

{role.name}

{role.use}

Source & recipe{role.source}{role.recipe}
+
{role.sample}
+
{role.review ?
Review

{role.review}

: null}
+
)} + {roles.length === 0 ?

No text roles match these filters.

: null} +
+
+ +
+

In context · shared components

These mount the real components with synthetic content. They stay connected to future component and token edits.

+

Shared Button labels: 14px normal; extra-small 12px.

+
+
+
{markdown}
+

Standalone Markdown has its own scale

Outside conversation regions, the shared renderer uses 15px body text at 1.6 leading. Proposed conversations override this to 14px/20px. Its H1–H4 rules resolve to 24, 20, 16.8 and 15.2px at the default root. Strong text is semibold; code uses Paper Mono. These values are outside a strict application of the proposed ramp.

In proposed conversations, final agent prose uses foreground; commentary and reasoning use muted foreground at 14px/20px. Human bubbles keep white text. Metadata and diagnostic output use 12px/16px. See Conversation type spec for the shared thread inventory.

Sources: ui/src/components/MarkdownBody.tsx and ui/src/index.css · .paperclip-markdown, .task-chat-reasoning-markdown.

{proseRoles.map(([, label]) =>
{label}
)}

Measurements above come from the rendered shared MarkdownBody at left. This sample does not simulate a live runner.

+
+
+ +
+

Decisions still to make

+
    +
  • Keep 14px task-row metadata as a deliberate exception, or introduce a smaller metadata role?
  • +
  • Keep 14px right-pane values beside 12px labels, or move more of the pane to xs?
  • +
  • Conversation Markdown is consolidated to 14px; decide separately whether standalone documents should adopt that scale.
  • +
  • Promote muted dashboard/chart headings to foreground, or explicitly retain their quieter hierarchy?
  • +
+

The remaining differences above are still open for review. The shared proposal typography is applied to Storybook and the sandbox. Scope: Design Proposals and the sandbox’s shell, Tasks, dashboard, agent overview and task conversation/detail surfaces. Unrelated product pages and light mode are outside this reference.

+
+
+
; +} diff --git a/ui/storybook/support/design-proposals/agent-workload-trial-data.ts b/ui/storybook/support/design-proposals/agent-workload-trial-data.ts new file mode 100644 index 00000000000..8c306a3469c --- /dev/null +++ b/ui/storybook/support/design-proposals/agent-workload-trial-data.ts @@ -0,0 +1,84 @@ +import type { Agent, Issue } from "@paperclipai/shared"; +import { createIssue, storybookAgents, storybookAuthSession, storybookCompanies } from "../../fixtures/paperclipData"; + +export const workloadAgentIds = { + mira: "agent-workload-trial-mira", + atlas: "agent-workload-trial-atlas", + sage: "agent-workload-trial-sage", + quinn: "agent-workload-trial-quinn", + rowan: "agent-workload-trial-rowan", + ember: "agent-workload-trial-ember", +} as const; + +const companyId = storybookCompanies[0]!.id; +const now = Date.now(); +const ago = (minutes: number) => new Date(now - minutes * 60_000); + +function agent(id: string, name: string, title: string, status: Agent["status"], role: Agent["role"] = "engineer"): Agent { + return { + ...storybookAgents[0]!, id, companyId, name, title, role, + urlKey: id, status, reportsTo: null, + capabilities: title, adapterConfig: {}, runtimeConfig: {}, + budgetMonthlyCents: 50_000, spentMonthlyCents: 0, + lastHeartbeatAt: status === "paused" ? ago(180) : ago(3), + pauseReason: status === "paused" ? "manual" : null, + pausedAt: status === "paused" ? ago(180) : null, + metadata: null, createdAt: ago(10_080), updatedAt: ago(3), + }; +} + +export const workloadAgents: Agent[] = [ + agent(workloadAgentIds.mira, "Mira", "Product engineer", "running"), + agent(workloadAgentIds.atlas, "Atlas — International Infrastructure and Reliability", "Infrastructure engineer", "running"), + agent(workloadAgentIds.sage, "Sage", "Research analyst", "idle", "researcher"), + agent(workloadAgentIds.quinn, "Quinn", "Quality engineer", "idle", "qa"), + agent(workloadAgentIds.rowan, "Rowan", "Content strategist", "paused", "general"), + agent(workloadAgentIds.ember, "Ember", "Release engineer", "running"), +]; + +function task(number: number, assigneeAgentId: string, title: string, status: Issue["status"], minutesAgo: number): Issue { + const updatedAt = ago(minutesAgo); + return createIssue({ + id: `agent-workload-trial-task-${number}`, companyId, assigneeAgentId, + assigneeUserId: null, responsibleUserId: storybookAuthSession.user.id, + identifier: `PAP-${number}`, issueNumber: number, title, + description: `Synthetic workload example: ${title}.`, status, priority: "medium", + projectId: null, projectWorkspaceId: null, project: null, + goalId: null, goal: null, parentId: null, + createdByUserId: storybookAuthSession.user.id, createdByAgentId: null, + checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, + executionWorkspaceId: null, executionWorkspacePreference: null, currentExecutionWorkspace: null, + labelIds: [], labels: [], blockedBy: [], blocks: [], billingCode: null, + isUnreadForMe: false, myLastTouchAt: updatedAt, lastExternalCommentAt: null, + lastActivityAt: updatedAt, startedAt: status === "todo" || status === "backlog" ? null : ago(1_440), + completedAt: status === "done" ? updatedAt : null, + createdAt: ago(4_320), updatedAt, + }); +} + +export const workloadTasks: Issue[] = [ + task(801, workloadAgentIds.mira, "Simplify the first-run workspace setup", "in_progress", 2), + task(802, workloadAgentIds.mira, "Resolve overlapping invitation states when a workspace owner retries an expired invitation", "in_progress", 7), + task(803, workloadAgentIds.mira, "Add setup guidance to the welcome checklist", "todo", 18), + task(804, workloadAgentIds.mira, "Explore a reusable workspace starter", "backlog", 190), + task(805, workloadAgentIds.mira, "Review the onboarding copy with customer success", "in_review", 12), + task(806, workloadAgentIds.mira, "Ship the invitation confirmation screen", "done", 55), + task(807, workloadAgentIds.mira, "Clarify ownership of the activation milestone", "blocked", 105), + task(808, workloadAgentIds.atlas, "Investigate intermittent replication lag across the international billing infrastructure before the next regional rollout", "in_progress", 3), + task(809, workloadAgentIds.atlas, "Prepare region-aware health checks", "in_progress", 9), + task(810, workloadAgentIds.atlas, "Document the failover rehearsal", "todo", 28), + task(811, workloadAgentIds.atlas, "Add regional latency budgets", "todo", 76), + task(812, workloadAgentIds.atlas, "Investigate read-replica capacity planning", "backlog", 220), + task(813, workloadAgentIds.atlas, "Review the EU rollout checklist", "in_review", 31), + task(814, workloadAgentIds.atlas, "Replace expired staging certificates", "done", 150), + task(815, workloadAgentIds.sage, "Summarize customer interviews for the planning review", "done", 40), + task(816, workloadAgentIds.sage, "Publish the competitor onboarding comparison", "done", 215), + task(817, workloadAgentIds.sage, "Validate the research brief with product", "in_review", 95), + task(818, workloadAgentIds.rowan, "Draft launch messaging for the partner pilot", "todo", 185), + task(819, workloadAgentIds.rowan, "Prepare customer-story interview questions", "backlog", 260), + task(820, workloadAgentIds.rowan, "Get approval for the revised campaign brief", "blocked", 181), + task(821, workloadAgentIds.rowan, "Publish the product update digest", "done", 330), + task(822, workloadAgentIds.ember, "Verify the release candidate against the smoke checklist", "in_progress", 5), + task(823, workloadAgentIds.ember, "Prepare the rollback rehearsal", "todo", 66), + task(824, workloadAgentIds.ember, "Review dependency updates for the release", "done", 130), +]; diff --git a/ui/storybook/support/design-proposals/agent-workload-trial.css b/ui/storybook/support/design-proposals/agent-workload-trial.css new file mode 100644 index 00000000000..7adddbfe079 --- /dev/null +++ b/ui/storybook/support/design-proposals/agent-workload-trial.css @@ -0,0 +1,10 @@ +/* Trial-local composition: the shared toolbar responds to viewport width, + while this collection can narrow independently when its context pane opens. */ +.agent-workload-trial [data-slot="collection-toolbar"] > :first-child { + flex-wrap: wrap; + gap: var(--design-page-toolbar-gap); +} + +.agent-workload-trial [data-slot="collection-toolbar-search"] { + flex-basis: var(--design-page-search-width); +} diff --git a/ui/storybook/support/design-proposals/button-usage.d.ts b/ui/storybook/support/design-proposals/button-usage.d.ts new file mode 100644 index 00000000000..f6ae2575590 --- /dev/null +++ b/ui/storybook/support/design-proposals/button-usage.d.ts @@ -0,0 +1,4 @@ +declare module "virtual:paperclip-button-usage" { + const inventory: { entries: { file: string; line: number; scope: "app" | "proposal"; variant: string; size: string }[] }; + export default inventory; +} diff --git a/ui/storybook/support/design-proposals/conversation-type-inventory.ts b/ui/storybook/support/design-proposals/conversation-type-inventory.ts new file mode 100644 index 00000000000..ded88dedca6 --- /dev/null +++ b/ui/storybook/support/design-proposals/conversation-type-inventory.ts @@ -0,0 +1,91 @@ +import type { CSSProperties } from "react"; + +const sourceRoot = "ui/src/components/task-chat/"; +const surfaces: Record = { + "task-chat-human-bubble": ["Human message", "TaskChatBubble.tsx"], + "task-chat-agent-bubble": ["Agent reply", "TaskChatBubble.tsx"], + "task-chat-agent-identity": ["Agent identity", "TaskChatBubble.tsx"], + "task-chat-live-text": ["Live commentary", "TaskChatLiveTail.tsx"], + "task-chat-phase-interstitial": ["Activity commentary", "TaskChatRunnerActivityGroup.tsx"], + "task-chat-thinking": ["Reasoning", "TaskChatThinking.tsx"], + "task-chat-runner-activity-detail": ["Activity detail", "TaskChatRunnerActivityGroup.tsx"], + "task-chat-activity-phase": ["Activity group", "TaskChatRunnerActivityGroup.tsx"], + "task-chat-tool-card": ["Tool activity", "TaskChatToolCard.tsx"], + "task-chat-live-run-pill": ["Run progress", "TaskChatLiveRunPill.tsx"], + "task-chat-turn-status-header": ["Native run progress", "TaskChatRunnerTurn.tsx"], + "task-chat-current-activity": ["Native current activity", "TaskChatRunnerTurn.tsx"], + "task-chat-final-response": ["Native final response", "TaskChatRunnerTurn.tsx"], + "task-chat-turn-summary": ["Activity footer", "TaskChatTurn.tsx"], + "task-chat-system-notice": ["System notice", "TaskChatSystemNotice.tsx"], + "task-chat-runtime-request": ["Runtime request", "TaskChatProtocolCard.tsx"], + "task-chat-runtime-request-history": ["Request receipt", "TaskChatProtocolCard.tsx"], + "task-chat-composer-takeover": ["Composer request", "TaskChatComposer.tsx"], + "task-chat-composer-input": ["Composer input", "TaskChatComposer.tsx"], + "task-chat-composer-actions": ["Composer controls", "TaskChatComposer.tsx"], + "paused-composer-takeover": ["Paused composer", "TaskChatPausedTakeover.tsx"], + "task-chat-collapsible-marker": ["Run outcome", "TaskChatMarker.tsx"], +}; +export type ConversationTypeRole = { + key: string; name: string; area: string; text: string; sampleStyle: CSSProperties; + family: string; size: string; leading: string; weight: string; color: string; + tracking: string; emphasis: string; opacity: string; metricKey: string; + states: string[]; sources: string[]; recipes: string[]; +}; +export type ConversationTypeInventory = { roles: ConversationTypeRole[]; states: string[]; capturedAt: string }; + +/** Inspect text already mounted by the shared sandbox composition. No imitation + * renderer or hardcoded font metrics; closed disclosures stay out of the census. */ +export function measureConversationType(root: HTMLElement): ConversationTypeInventory { + const roles = new Map(); + const cases = [...root.querySelectorAll("[data-preview-case]")]; + for (const example of cases) { + const walker = document.createTreeWalker(example, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode; + const parent = node.parentElement; + const text = node.textContent?.replace(/\s+/g, " ").trim(); + if (!text || !parent || parent.closest('[aria-hidden="true"], .sr-only, svg, script, style') || !parent.getClientRects().length) continue; + if (parent === example.firstElementChild && parent.tagName === "H3") continue; + const computed = getComputedStyle(parent); + if (computed.visibility !== "visible" || computed.opacity === "0") continue; + let area = "Conversation support"; + let source = "ui/src/components/design-sandbox/DesignSandboxTaskThread.tsx"; + const recipes: string[] = []; + let opacity = 1; + for (let ancestor: HTMLElement | null = parent; ancestor && ancestor !== example; ancestor = ancestor.parentElement) { + opacity *= Number(getComputedStyle(ancestor).opacity); + if (ancestor.classList.length) recipes.push(`${ancestor.tagName.toLowerCase()}.${[...ancestor.classList].join(".")}`); + const id = ancestor.dataset.testid; + if (id && surfaces[id] && area === "Conversation support") { + [area, source] = [surfaces[id][0], sourceRoot + surfaces[id][1]]; + } + if (id && /^(task-chat-provider-|task-chat-workspace-|task-chat-run-result|task-chat-run-terminal|task-chat-resource-)/.test(id) && area === "Conversation support") { + [area, source] = ["Structured activity", sourceRoot + "TaskChatProtocolCard.tsx"]; + } + } + if (!opacity) continue; + const semantic = parent.closest("code, pre, strong, b, em, i, a, blockquote, h1, h2, h3, h4, h5, h6, li, button, label, textarea, p"); + const tag = semantic?.tagName.toLowerCase(); + const form = parent.closest("button") ? "Control label" : tag === "code" || tag === "pre" ? "Code / command" : tag === "strong" || tag === "b" ? "Strong emphasis" : tag === "em" || tag === "i" ? "Italic emphasis" : tag === "a" ? "Link" : tag === "li" ? "List text" : tag === "p" ? "Body text" : tag?.startsWith("h") ? "Heading" : /^\d{1,2}:\d\d [AP]M$/.test(text) ? "Timestamp" : "Supporting text"; + const values = { + family: computed.fontFamily, size: computed.fontSize, leading: computed.lineHeight, + weight: computed.fontWeight, color: computed.color, tracking: computed.letterSpacing, + emphasis: `${computed.fontStyle} / ${computed.textDecorationLine}`, opacity: String(Number(opacity.toFixed(3))), + }; + const metricKey = JSON.stringify([values.family, values.size, values.leading, values.weight, values.tracking, computed.fontStyle]); + const key = JSON.stringify([area, form, values]); + const state = example.dataset.previewCase!; + const role = roles.get(key) ?? { + key, metricKey, name: `${area} · ${form.toLowerCase()}`, area, text, ...values, + sampleStyle: { fontFamily: values.family, fontSize: values.size, lineHeight: values.leading, fontWeight: values.weight, color: values.color, letterSpacing: values.tracking, fontStyle: computed.fontStyle, textDecorationLine: computed.textDecorationLine, opacity: values.opacity }, + states: [], sources: [], recipes: [], + }; + if (!role.states.includes(state)) role.states.push(state); + if (!role.sources.includes(source)) role.sources.push(source); + if (parent.closest(".paperclip-markdown") && !role.sources.includes("ui/src/index.css · .paperclip-markdown")) role.sources.push("ui/src/index.css · .paperclip-markdown"); + if (role.recipes.length === 0) role.recipes = recipes; + roles.set(key, role); + } + } + return { roles: [...roles.values()].sort((a, b) => a.area.localeCompare(b.area) || parseFloat(b.size) - parseFloat(a.size) || a.name.localeCompare(b.name)), states: cases.map(example => example.dataset.previewCase!), capturedAt: new Date().toLocaleTimeString() }; +} diff --git a/ui/storybook/support/design-proposals/project-health-trial-data.ts b/ui/storybook/support/design-proposals/project-health-trial-data.ts new file mode 100644 index 00000000000..a5844039006 --- /dev/null +++ b/ui/storybook/support/design-proposals/project-health-trial-data.ts @@ -0,0 +1,167 @@ +import type { Issue, Project } from "@paperclipai/shared"; +import { + createIssue, + storybookAuthSession, + storybookCompanies, +} from "../../fixtures/paperclipData"; + +export const projectHealthProjectIds = { + onboarding: "project-health-trial-onboarding", + international: "project-health-trial-international", + knowledge: "project-health-trial-knowledge", + partners: "project-health-trial-partners", + developer: "project-health-trial-developer", + launch: "project-health-trial-launch", +} as const; + +const companyId = storybookCompanies[0]!.id; +const authorId = storybookAuthSession.user.id; + +export const projectHealthProjects: Pick< + Project, + "id" | "companyId" | "name" | "description" | "status" +>[] = [ + { + id: projectHealthProjectIds.onboarding, + companyId, + name: "Customer onboarding", + description: "Help new teams reach their first successful workflow with fewer handoffs.", + status: "in_progress", + }, + { + id: projectHealthProjectIds.international, + companyId, + name: "International expansion and multi-region billing infrastructure", + description: "Prepare regional invoicing, local currencies and a reliable rollout for new markets.", + status: "in_progress", + }, + { + id: projectHealthProjectIds.knowledge, + companyId, + name: "Knowledge base refresh", + description: "Turn recurring support questions into clear, searchable product guidance.", + status: "in_progress", + }, + { + id: projectHealthProjectIds.partners, + companyId, + name: "Partner integrations", + description: "Scope the first partner workflows before planning the implementation.", + status: "planned", + }, + { + id: projectHealthProjectIds.developer, + companyId, + name: "Developer experience", + description: "Reduce setup friction and make everyday development feedback faster.", + status: "backlog", + }, + { + id: projectHealthProjectIds.launch, + companyId, + name: "Autumn launch", + description: "Ship the autumn release with a coordinated announcement and support handoff.", + status: "completed", + }, +]; + +const fixtureNow = new Date("2026-10-16T16:00:00.000Z"); + +function task( + number: number, + projectId: string, + title: string, + status: Issue["status"], + minutesAgo: number, + description: string, +): Issue { + const updatedAt = new Date(fixtureNow.getTime() - minutesAgo * 60_000); + return createIssue({ + id: `project-health-trial-task-${number}`, + companyId, + projectId, + projectWorkspaceId: null, + project: null, + goalId: null, + goal: null, + parentId: null, + title, + description, + status, + priority: status === "blocked" ? "high" : "medium", + issueNumber: number, + identifier: `PAP-${number}`, + createdByUserId: authorId, + createdByAgentId: null, + assigneeAgentId: null, + assigneeUserId: authorId, + responsibleUserId: authorId, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + currentExecutionWorkspace: null, + billingCode: null, + labelIds: [], + labels: [], + blockedBy: [], + blocks: [], + startedAt: status === "todo" || status === "backlog" ? null : new Date("2026-10-12T16:00:00.000Z"), + completedAt: status === "done" ? updatedAt : null, + isUnreadForMe: false, + myLastTouchAt: updatedAt, + lastExternalCommentAt: null, + lastActivityAt: updatedAt, + createdAt: new Date("2026-10-09T16:00:00.000Z"), + updatedAt, + }); +} + +export const projectHealthTasks: Issue[] = [ + task(701, projectHealthProjectIds.onboarding, "Confirm the activation milestone with customer success", "blocked", 8, + "Resolve the ownership question before publishing the new onboarding checklist."), + task(702, projectHealthProjectIds.onboarding, "Review the first-run checklist with the pilot team", "in_review", 24, + "The pilot feedback is ready for review, including the two steps that still need clearer copy."), + task(703, projectHealthProjectIds.onboarding, "Simplify workspace invitations", "in_progress", 46, + "Keep the invitation flow understandable for teams adding their first collaborators."), + task(704, projectHealthProjectIds.onboarding, "Draft welcome messages for new workspace owners", "todo", 78, + "Explain the next useful action without overwhelming a new workspace owner."), + task(705, projectHealthProjectIds.onboarding, "Add a sample workflow to the starter workspace", "done", 120, + "Provide a complete example that a new team can inspect before creating its own workflow."), + task(706, projectHealthProjectIds.onboarding, "Measure time to first completed task", "done", 186, + "Document the baseline and the definition used to compare the next onboarding iteration."), + task(707, projectHealthProjectIds.onboarding, "Document assisted onboarding handoffs", "todo", 244, + "Capture when customer success should step in and what context should accompany the handoff."), + task(708, projectHealthProjectIds.international, "Resolve invoice rounding differences across currencies before the multi-region billing pilot begins", "blocked", 12, + "Reconcile line-item and invoice totals for currencies with different decimal precision."), + task(709, projectHealthProjectIds.international, "Review regional invoice templates", "in_review", 38, + "Check the proposed regional fields and the examples prepared for the pilot."), + task(710, projectHealthProjectIds.international, "Implement currency selection for new subscriptions", "in_progress", 91, + "Apply the selected billing currency consistently throughout checkout and subscription details."), + task(711, projectHealthProjectIds.international, "Prepare the regional rollout checklist", "todo", 170, + "List the launch owners, support coverage and rollback conditions for each region."), + task(712, projectHealthProjectIds.international, "Inventory supported payment methods", "done", 302, + "Record which payment methods are available to the initial regional pilot accounts."), + task(713, projectHealthProjectIds.knowledge, "Refresh the workspace setup guide", "done", 31, + "Update the setup guide to match the current workspace creation flow."), + task(714, projectHealthProjectIds.knowledge, "Rewrite the task ownership examples", "todo", 116, + "Use realistic examples to explain assignment, responsibility and review handoffs."), + task(715, projectHealthProjectIds.knowledge, "Publish the troubleshooting index", "done", 218, + "Make the most common setup problems easy to find from a single support entry point."), + task(716, projectHealthProjectIds.knowledge, "Add screenshots to the project guide", "todo", 357, + "Show the project overview and its task list using the current interface."), + task(717, projectHealthProjectIds.developer, "Map the slowest local feedback loops", "todo", 63, + "Collect baseline timings for the development tasks the team repeats every day."), + task(718, projectHealthProjectIds.developer, "Explore a guided development setup", "backlog", 421, + "Outline a setup flow that explains each missing prerequisite at the point it is needed."), + task(719, projectHealthProjectIds.launch, "Publish the release announcement", "done", 52, + "Publish the approved product announcement and link to the release notes."), + task(720, projectHealthProjectIds.launch, "Complete launch readiness review", "done", 133, + "Confirm the release checklist, support coverage and follow-up owners."), + task(721, projectHealthProjectIds.launch, "Hand off release notes to support", "done", 266, + "Give support the user-facing changes and answers to expected launch questions."), + task(722, projectHealthProjectIds.launch, "Archive the launch retrospective", "done", 388, + "Capture the rollout results and the process improvements for the next release."), +]; diff --git a/ui/storybook/support/design-proposals/task-list-data.ts b/ui/storybook/support/design-proposals/task-list-data.ts new file mode 100644 index 00000000000..af5d11fb7ca --- /dev/null +++ b/ui/storybook/support/design-proposals/task-list-data.ts @@ -0,0 +1,83 @@ +import type { Issue } from "@paperclipai/shared"; +import type { TaskListItem } from "@/components/TaskList"; +import type { TaskListColumnId } from "@/lib/task-list"; +import { createIssue, storybookAgentMap, storybookExecutionWorkspaces, storybookIssueLabels } from "../../fixtures/paperclipData"; + +export const longTitle = "Document connector recovery behavior when an expired credential interrupts an active task and requires a human to reconnect the account"; + +export function task(number: number, title: string, status: Issue["status"], parentId: string | null = null): Issue { + return createIssue({ + id: `row-design-${number}`, + identifier: `PAP-${number}`, + issueNumber: number, + title, + status, + parentId, + labelIds: [], + labels: [], + checkoutRunId: null, + executionRunId: null, + executionLockedAt: null, + createdAt: new Date("2026-10-14T16:00:00Z"), + updatedAt: new Date(Date.UTC(2026, 9, 14, 17, 0, number % 60)), + lastActivityAt: new Date("2026-10-14T17:00:00Z"), + }); +} + +export const connectorParent = task(428, "Improve connector reliability", "in_progress"); +export const healthTask = task(429, "Define connector health states", "in_review", connectorParent.id); +export const recoveryParent = task(430, "Design reconnect and recovery flows", "todo", connectorParent.id); +export const recoveryChild = task(431, longTitle, "todo", recoveryParent.id); +export const recoverySibling = task(432, "Review failure messages with the team", "done", connectorParent.id); +export const chatTask = task(454, "Chat style tasks v2", "done"); +export const emptyTask = task(455, "Refine the empty task-list state", "backlog"); +export const readTask = task(456, "Review the connector experience", "todo"); +export const unreadTask = task(457, "Define connector health states", "todo"); + +export const treeTasks = [connectorParent, healthTask, recoveryParent, recoveryChild, recoverySibling, chatTask, emptyTask]; +export const ownerFor = (issue: Issue) => [429, 430, 432, 455].includes(issue.issueNumber ?? 0) ? "Design" : "Codex"; + +export function columnData(issue: Issue, owner: string) { + const agent = storybookAgentMap.get("agent-codex")!; + const labels = issue.status === "backlog" ? [] : storybookIssueLabels.slice(0, issue.status === "in_review" ? 2 : 1); + const branch = issue.status === "backlog" ? null : `codex/pap-${issue.issueNumber}`; + const rowIssue = { + ...issue, + createdByAgentId: issue.parentId ? agent.id : null, + createdByUserId: issue.parentId ? null : "user-board", + assigneeAgentId: issue.status !== "backlog" && owner === "Codex" ? agent.id : null, + assigneeUserId: issue.status !== "backlog" && owner === "Design" ? "user-design" : null, + labels, + labelIds: labels.map(label => label.id), + project: issue.status === "backlog" ? null : issue.project, + projectId: issue.status === "backlog" ? null : issue.projectId, + executionWorkspaceId: branch ? `worktree-${issue.id}` : null, + currentExecutionWorkspace: branch ? { ...storybookExecutionWorkspaces[0]!, id: `worktree-${issue.id}`, sourceIssueId: issue.id, branchName: branch } : null, + }; + return { + issue: rowIssue, + parent: treeTasks.find(candidate => candidate.id === issue.parentId), + creator: rowIssue.createdByAgentId ? agent.name : "Scott", + assignee: rowIssue.assigneeAgentId ? agent.name : rowIssue.assigneeUserId ? "Design" : null, + }; +} + +export function sortValue(issue: Issue, column: TaskListColumnId) { + const data = columnData(issue, ownerFor(issue)); + switch (column) { + case "task": return issue.title; + case "taskId": return issue.identifier; + case "createdBy": return data.creator; + case "assignee": return data.assignee; + case "project": return data.issue.project?.name; + case "parentTask": return data.parent ? `${data.parent.identifier} ${data.parent.title}` : null; + case "tags": return data.issue.labels.map(label => label.name).sort().join(", "); + case "lastUpdated": return new Date(issue.updatedAt).getTime(); + case "worktree": return data.issue.currentExecutionWorkspace?.branchName; + } +} + +export const reviewTaskItems: TaskListItem[] = treeTasks.map(issue => { + const data = columnData(issue, ownerFor(issue)); + return { issue: data.issue, createdBy: data.creator, assignee: data.assignee, unread: issue === connectorParent || issue === healthTask }; +}); diff --git a/ui/storybook/support/design-proposals/task-page-data.ts b/ui/storybook/support/design-proposals/task-page-data.ts new file mode 100644 index 00000000000..9b198f6f683 --- /dev/null +++ b/ui/storybook/support/design-proposals/task-page-data.ts @@ -0,0 +1,16 @@ +import type { TaskListItem } from "@/components/TaskList"; +import { columnData, reviewTaskItems, task } from "./task-list-data"; + +const extraTasks: TaskListItem[] = [ + task(458, "Simplify the first company setup", "in_progress"), + task(459, "Review budget notifications", "in_review"), + task(460, "Collect feedback on the refreshed task list", "todo"), +].map((issue, index) => { + const data = columnData(issue, index === 2 ? "Design" : "Codex"); + return { issue: data.issue, createdBy: data.creator, assignee: data.assignee, unread: index === 0 }; +}); +export const initialItems = [...reviewTaskItems, ...extraTasks]; +export const recentTasks = [...initialItems] + .filter(item => !item.issue.parentId) + .sort((left, right) => new Date(right.issue.updatedAt).getTime() - new Date(left.issue.updatedAt).getTime()) + .slice(0, 5); diff --git a/ui/storybook/support/design-proposals/typography-specification.ts b/ui/storybook/support/design-proposals/typography-specification.ts new file mode 100644 index 00000000000..67e62472330 --- /dev/null +++ b/ui/storybook/support/design-proposals/typography-specification.ts @@ -0,0 +1,81 @@ +import type { CSSProperties } from "react"; + +export const typeRamp = [ + { token: "3xl", size: 30, className: "text-3xl", use: "Dashboard metric values on desktop.", source: "MetricCard.tsx · sm:text-3xl" }, + { token: "2xl", size: 24, className: "text-2xl", use: "Task detail title; agent name; metrics below the sm breakpoint.", source: "IssueDetail.tsx · AgentDetail.tsx · MetricCard.tsx" }, + { token: "xl", size: 20, className: "text-xl", use: "Available heading step. Rendered Markdown H2 currently also resolves to 20px, through its own rule.", source: "index.css · .paperclip-markdown h2" }, + { token: "lg", size: 18, className: "text-lg", use: "Section headings in foundation specimens. No default product role assigned here.", source: "ColorsPreview.tsx · text-lg" }, + { token: "base", size: 16, className: "text-base", use: "Mobile text-entry controls; available larger body step. Desktop body generally uses sm.", source: "ui/input.tsx · index.css mobile editable-control tokens" }, + { token: "sm", size: 14, className: "text-sm", use: "Main content, task/activity rows, navigation, buttons, menus and composer text.", source: "TaskList · SidebarShell · ui/button · TaskChat" }, + { token: "compact", size: 13, className: "text-(length:--text-compact)", use: "Compact controls and legacy navigation recipes. Proposal navigation overrides this to sm.", source: "SidebarNavItem.tsx · SidebarAccountMenu.tsx" }, + { token: "xs", size: 12, className: "text-xs", use: "Property labels, supporting copy, hover-card metadata, activity details, message times, account email and run metrics.", source: "issue-properties/primitives.tsx · IssueLinkQuicklook · TaskChat" }, + { token: "nano", size: 10, className: "text-(length:--text-nano)", use: "Navigation/streamlined property eyebrows; chart dates, legends and subtitles.", source: "SidebarSection.tsx · issue-properties/primitives.tsx · ActivityCharts.tsx" }, +] as const; + +export type TypeArea = "Navigation" | "Tasks & activity" | "Dashboard & agents" | "Menus & previews" | "Detail & properties" | "Conversation & forms"; +export type TypeRole = { + id: string; + area: TypeArea; + name: string; + sample: string; + className: string; + style?: CSSProperties; + recipe: string; + source: string; + use: string; + review?: string; + inheritedLeading?: boolean; +}; + +const taskType: CSSProperties = { fontSize: "var(--task-row-title-size)", lineHeight: "var(--task-row-title-leading)" }; +const flyoutType: CSSProperties = { fontSize: "var(--flyout-type-size)", lineHeight: "var(--flyout-type-leading)" }; +const monoTask: CSSProperties = { ...taskType, fontFamily: "var(--task-row-font-mono)" }; + +/** Source-derived recipes, not a second component implementation. Sources are repository-relative. */ +export const typeRoles: TypeRole[] = [ + { id: "shell-heading", area: "Navigation", name: "Page / breadcrumb section", sample: "TASKS", className: "font-semibold uppercase text-foreground", style: { fontSize: "var(--design-page-type-size)", lineHeight: "var(--design-page-type-leading)", letterSpacing: "var(--design-page-heading-tracking)" }, recipe: "--design-page-type-size / leading / heading-tracking", source: "ui/src/index.css · .design-page-header :is(h1, h2)", use: "Compact section identity in the center-pane header." }, + { id: "organization", area: "Navigation", name: "Organization name", sample: "Design Sandbox", className: "text-sm font-semibold leading-normal text-foreground", recipe: "text-sm · font-semibold · leading-normal", source: "ui/src/components/SidebarCompanyMenu.tsx", use: "The organization trigger has a stronger weight than ordinary navigation." }, + { id: "nav-item", area: "Navigation", name: "Navigation item", sample: "Dashboard", className: "text-sm font-normal leading-normal text-muted-foreground", recipe: "--design-sidebar-label-size / leading · font-normal", source: "ui/src/index.css · .design-sidebar-navigation; ui/src/components/SidebarNavItem.tsx", use: "Muted by default; selected/hovered items use foreground." }, + { id: "nav-eyebrow", area: "Navigation", name: "Navigation group label", sample: "RECENT TASKS", className: "design-navigation-group-label", recipe: "--text-nano · --design-sidebar-heading-tracking", source: "ui/src/components/SidebarSection.tsx; ui/src/index.css · shared group-label rule", use: "Inter, 10px / 15px, normal, uppercase, 0.1em tracking and muted foreground at 60%. Shared with streamlined property sections." }, + { id: "nav-recent", area: "Navigation", name: "Recent link / account name", sample: "Improve connector reliability", className: "text-sm font-normal leading-normal text-muted-foreground", recipe: "--design-sidebar-label-size / leading", source: "ui/src/components/SidebarRecentTasks.tsx; ui/src/components/SidebarAccountMenu.tsx", use: "Single-line recent links; account name is foreground. Long links share the marquee behavior." }, + { id: "row-read", area: "Tasks & activity", name: "Read task title", sample: "Improve connector reliability", className: "font-normal text-muted-foreground", style: taskType, recipe: "--task-row-title-size / leading · weight-read", source: "ui/src/index.css · .task-row-refreshed; ui/src/components/IssueRow.tsx", use: "Default read state. Hover/focus promotes color while preserving regular weight." }, + { id: "row-unread", area: "Tasks & activity", name: "Unread task title", sample: "Review connector health states", className: "font-semibold text-foreground", style: taskType, recipe: "--task-row-title-weight-unread · --task-row-title-color-active", source: "ui/src/index.css · .task-row-refreshed", use: "Semibold and foreground; unread state is separate from density." }, + { id: "row-id", area: "Tasks & activity", name: "Task ID / parent / worktree", sample: "DES-431", className: "font-normal text-muted-foreground", style: monoTask, recipe: "--task-row-font-mono · --task-row-title-size / leading", source: "ui/src/components/TaskList.tsx; ui/src/index.css · .task-row-refreshed", use: "Paper Mono aligns identifiers across task tables and activity rows.", review: "Task table metadata is 14px, rather than the general 12px metadata guidance." }, + { id: "row-time", area: "Tasks & activity", name: "Row timestamp / column label", sample: "2h ago", className: "font-normal text-muted-foreground", style: taskType, recipe: "--task-row-title-size / leading", source: "ui/src/components/TaskList.tsx; ui/src/components/ActivityRow.tsx", use: "Same size as the title; column headings also keep regular weight.", review: "Timestamps stay 14px in the shared row; narrow presentation hides columns instead of shrinking type." }, + { id: "activity-text", area: "Tasks & activity", name: "Activity actor, verb & subject", sample: "Board updated Connector engineer", className: "font-normal text-muted-foreground", style: taskType, recipe: "task-row-refreshed · task-row-title", source: "ui/src/components/ActivityRow.tsx", use: "Task and non-task activity share row metrics, truncation and hover promotion." }, + { id: "toolbar", area: "Tasks & activity", name: "Toolbar control / search", sample: "Search tasks…", className: "text-sm font-normal leading-5 text-muted-foreground", recipe: "--design-page-type-size / leading · font-normal", source: "ui/src/components/TaskListToolbar.tsx; ui/src/index.css · .design-task-search", use: "Controls are normal; search input text is regular and foreground, placeholder muted. New Task is semibold." }, + { id: "result-count", area: "Tasks & activity", name: "Result count / empty state", sample: "10 matching tasks", className: "text-sm leading-5 text-muted-foreground", recipe: "text-sm · leading-5", source: "ui/src/pages/DesignSandboxTasks.tsx; ui/src/pages/Dashboard.tsx", use: "Supporting collection feedback without heading emphasis." }, + { id: "metric", area: "Dashboard & agents", name: "Metric value", sample: "$0.00", className: "text-2xl font-semibold tracking-tight tabular-nums text-foreground sm:text-3xl", recipe: "text-2xl sm:text-3xl · font-semibold · tabular-nums", source: "ui/src/components/MetricCard.tsx", use: "30px desktop; 24px below sm. Tabular numerals keep changing values aligned." }, + { id: "metric-label", area: "Dashboard & agents", name: "Metric label / description", sample: "Agents Enabled", className: "text-xs font-normal text-muted-foreground sm:text-sm", recipe: "text-xs sm:text-sm · font-normal", source: "ui/src/components/MetricCard.tsx", use: "Label: 14px desktop / 12px small. Description: 12px regular, muted at 70%, hidden on small screens." }, + { id: "dashboard-heading", area: "Dashboard & agents", name: "Dashboard section heading", sample: "RECENT ACTIVITY", className: "text-sm font-semibold uppercase tracking-wide text-muted-foreground", recipe: "text-sm · font-semibold · uppercase · tracking-wide", source: "ui/src/pages/Dashboard.tsx", use: "Labels dashboard sections above the cards.", review: "Currently muted; the general proposed heading rule calls for foreground." }, + { id: "chart-heading", area: "Dashboard & agents", name: "Chart title / empty chart", sample: "Tasks by Status", className: "text-xs font-normal text-muted-foreground", recipe: "text-xs · font-normal", source: "ui/src/components/ActivityCharts.tsx · ChartCard", use: "Chart title is 12px normal; an empty-chart message uses regular weight.", review: "Chart headings are currently muted rather than foreground." }, + { id: "chart-caption", area: "Dashboard & agents", name: "Chart date / legend / subtitle", sample: "10/15 · In Progress", className: "text-(length:--text-nano) tabular-nums text-muted-foreground", recipe: "--text-nano · tabular-nums for dates", source: "ui/src/components/ActivityCharts.tsx · DateLabels / ChartLegend", use: "10px chart captions; subtitles use muted foreground at 60%.", inheritedLeading: true }, + { id: "agent-name", area: "Dashboard & agents", name: "Agent name", sample: "Connector engineer", className: "text-2xl font-semibold tracking-tight text-foreground", recipe: "text-2xl · font-semibold · tracking-tight", source: "ui/src/pages/AgentDetail.tsx", use: "Agent overview identity. Panel headings below it use 14px normal; panel values use 14px regular." }, + { id: "menu-heading", area: "Menus & previews", name: "Popover heading", sample: "Views", className: "font-semibold text-foreground", style: flyoutType, recipe: "--flyout-type-size / leading · font-semibold", source: "ui/src/components/ui/flyout.tsx; ui/src/index.css · .design-flyout-heading", use: "The shared popover heading establishes menu hierarchy." }, + { id: "menu-option", area: "Menus & previews", name: "Popover option / checkmark", sample: "Needs attention", className: "font-normal text-muted-foreground", style: flyoutType, recipe: "--flyout-type-size / leading · muted-foreground", source: "ui/src/index.css · .design-flyout-option", use: "Unselected labels and accompanying checks are muted; hover/focus uses foreground. Action menus use normal weight." }, + { id: "account-name", area: "Menus & previews", name: "Account name", sample: "Riley Board", className: "text-sm font-semibold text-foreground", style: { lineHeight: "var(--profile-popover-label-line-height)" }, recipe: "text-sm · --profile-popover-label-line-height", source: "ui/src/components/SidebarAccountMenu.tsx", use: "14px semibold / 18px leading in the account header." }, + { id: "account-email", area: "Menus & previews", name: "Account email / ID", sample: "riley@paperclip.local", className: "text-xs text-muted-foreground", style: { lineHeight: "var(--profile-popover-meta-line-height)" }, recipe: "--text-xs · --profile-popover-meta-line-height", source: "ui/src/components/SidebarAccountMenu.tsx", use: "12px / 16px. Optional account ID uses Paper Mono." }, + { id: "hover-title", area: "Menus & previews", name: "Task hover-card title", sample: "Review task thread layouts", className: "font-semibold text-foreground", style: { fontSize: "var(--task-preview-title-size)", lineHeight: "var(--task-preview-content-leading)" }, recipe: "--task-preview-title-size / content-leading", source: "ui/src/components/IssueLinkQuicklook.tsx; ui/src/index.css", use: "14px / 18px; title wraps within the preview." }, + { id: "hover-body", area: "Menus & previews", name: "Hover-card description", sample: "Review shared components and foundations.", className: "text-muted-foreground", style: { fontSize: "var(--task-preview-meta-size)", lineHeight: "var(--task-preview-content-leading)" }, recipe: "--task-preview-meta-size / content-leading", source: "ui/src/components/IssueLinkQuicklook.tsx; ui/src/index.css", use: "12px / 18px; task ID, project and timestamp use 12px / 16px metadata tokens." }, + { id: "detail-title", area: "Detail & properties", name: "Task detail title", sample: "Improve connector reliability", className: "text-2xl font-normal leading-normal text-foreground", recipe: "text-2xl · font-normal · leading-normal", source: "ui/src/pages/IssueDetail.tsx · InlineEditor h2", use: "24px regular / 36px leading. Status, ID, project and people sit below the title." }, + { id: "detail-id", area: "Detail & properties", name: "Task detail identifier", sample: "DES-431", className: "font-mono text-sm text-muted-foreground", recipe: "font-mono · text-sm", source: "ui/src/pages/IssueDetail.tsx", use: "Paper Mono identifier in the task title metadata row, matching task tables." }, + { id: "property-label", area: "Detail & properties", name: "Property label / supporting metadata", sample: "Assignee", className: "text-xs text-muted-foreground", recipe: "text-xs · text-muted-foreground", source: "ui/src/components/issue-properties/primitives.tsx · PropertyRow", use: "12px labels and supporting copy in the right pane." }, + { id: "property-value", area: "Detail & properties", name: "Property value", sample: "Connector engineer", className: "text-sm text-foreground", recipe: "text-sm; contextual foreground / muted-foreground", source: "ui/src/components/issue-properties/IssueProperties.tsx", use: "Assignee, project, status and several relationship values currently use 14px.", review: "The right pane mixes 12px labels and 14px values; it does not yet follow a blanket 12px rule." }, + { id: "property-eyebrow", area: "Detail & properties", name: "Streamlined property section", sample: "RELATIONSHIPS", className: "design-navigation-group-label", recipe: "Same shared rule as navigation group label", source: "ui/src/components/issue-properties/primitives.tsx · PropertySection", use: "Matches navigation group labels exactly: Inter, 10px / 15px, normal, uppercase, 0.1em tracking and muted foreground at 60%.", inheritedLeading: true }, + { id: "panel-tab", area: "Detail & properties", name: "Pane tab / overview panel heading", sample: "Properties", className: "text-sm font-normal text-foreground", recipe: "text-sm · font-normal", source: "ui/src/components/side-panel/SidePanelTab.tsx; ui/src/pages/AgentDetail.tsx", use: "Active tab and compact card headings; inactive tabs use muted foreground." }, + { id: "message-author", area: "Conversation & forms", name: "Conversation author name", sample: "Connector engineer", className: "text-sm font-semibold text-foreground", recipe: "--conversation-body-size / --conversation-body-leading · font-semibold · foreground", source: "ui/src/components/task-chat/TaskChatBubble.tsx", use: "Proposed agent names use 14px/20px semibold in foreground. Final-response headings use 14px normal in foreground. Request and protocol card titles remain 14px normal." }, + { id: "markdown-body", area: "Conversation & forms", name: "Standalone rendered prose", sample: "I reviewed the shared components and their usage.", className: "paperclip-markdown", recipe: ".paperclip-markdown", source: "ui/src/components/MarkdownBody.tsx; ui/src/index.css · .paperclip-markdown", use: "Paragraphs, lists and message content use the shared Markdown renderer. See In context for measured heading, emphasis, quote and code roles.", review: "Standalone prose remains 15px / 24px. Inside proposed conversation regions, authored text and headings use 14px/20px; final replies use foreground and commentary uses muted foreground." }, + { id: "working", area: "Conversation & forms", name: "Live working status", sample: "Working", className: "text-sm font-normal text-foreground/80", recipe: "text-sm · font-normal · foreground/80", source: "ui/src/components/task-chat/TaskChatLiveRunPill.tsx", use: "Elapsed time and tool count use 12px with quieter muted opacity. State color can convey live execution." }, + { id: "activity-detail", area: "Conversation & forms", name: "Thinking / tool activity / system notice", sample: "Read files · 3 tools", className: "text-xs text-muted-foreground", recipe: "text-xs · text-muted-foreground", source: "ui/src/components/task-chat/TaskChatThinking.tsx; TaskChatRunnerActivityGroup.tsx; TaskChatSystemNotice.tsx", use: "Collapsed reasoning, tools, phase details and notice labels. Expanded prose is rendered separately." }, + { id: "message-time", area: "Conversation & forms", name: "Message timestamp / delivery state", sample: "4:05 PM · Sending…", className: "text-xs text-muted-foreground", recipe: "text-xs", source: "ui/src/components/task-chat/TaskChatBubble.tsx; TaskChatTurn.tsx", use: "12px message footer; not the shared task-row timestamp role." }, + { id: "run-metric", area: "Conversation & forms", name: "Run metrics / technical identifiers", sample: "42,000 tokens · 930ms", className: "font-mono text-xs text-muted-foreground", recipe: "font-mono (Paper Mono) · text-xs", source: "ui/src/components/task-chat/TaskChatTurn.tsx", use: "Compact technical counters, run IDs and activity diagnostics in Paper Mono at 12px / 16px." }, + { id: "question-prompt", area: "Conversation & forms", name: "Question / confirmation prompt", sample: "What should happen next?", className: "text-sm font-normal text-foreground", recipe: "text-sm · font-normal", source: "ui/src/components/task-chat/TaskChatProtocolCard.tsx", use: "Confirmation prompts and protocol interactions; field labels have a tighter leading recipe." }, + { id: "form-label", area: "Conversation & forms", name: "Field label", sample: "Task name", className: "text-sm font-normal leading-none text-foreground", recipe: "text-sm · leading-none · font-normal", source: "ui/src/components/ui/label.tsx", use: "The shared Label primitive uses a tight 14px line box, separate from prose or a question prompt." }, + { id: "input", area: "Conversation & forms", name: "Input / composer text", sample: "Message Connector engineer…", className: "text-sm text-muted-foreground", recipe: "text-sm on desktop; text-base for mobile entry", source: "ui/src/components/ui/input.tsx; ui/src/components/task-chat/TaskChatComposer.tsx", use: "14px desktop entry, muted placeholder; typed task-search text uses foreground. Mobile entry grows to 16px." }, + { id: "button", area: "Conversation & forms", name: "Button label", sample: "Continue", className: "text-sm font-normal text-foreground", recipe: "text-sm · font-normal; size=xs uses text-xs", source: "ui/src/components/ui/button.tsx", use: "Most buttons are 14px normal; extra-small buttons use 12px. CTA text uses the contrasting background token." }, + { id: "validation", area: "Conversation & forms", name: "Validation / helper copy", sample: "Choose an option to continue.", className: "text-xs text-destructive", recipe: "text-xs · destructive / muted-foreground", source: "ui/src/components/task-chat/TaskChatProtocolCard.tsx; TaskChatComposer.tsx", use: "12px. Errors use semantic destructive color; normal hints remain muted. Status color is an intentional exception." }, + { id: "compact-control", area: "Conversation & forms", name: "Compact control recipe", sample: "Documentation", className: "text-(length:--text-compact) font-normal text-muted-foreground", recipe: "--text-compact · font-normal", source: "ui/src/components/SidebarAccountMenu.tsx; SidebarNavItem.tsx", use: "13px base recipes exist; the proposal flyout and sidebar override these to 14px. This is not the current proposal menu size.", inheritedLeading: true }, + { id: "avatar", area: "Menus & previews", name: "Fallback avatar initial", sample: "R", className: "text-xs font-normal text-muted-foreground", recipe: "text-xs · single first initial", source: "ui/src/components/SidebarAccountMenu.tsx; ui/src/components/ui/avatar-initials.tsx", use: "One character for fallback identity; use the avatar component rather than constructing initials in each view." }, + { id: "badge", area: "Menus & previews", name: "Badge / chip label", sample: "Design system", className: "text-xs font-normal text-muted-foreground", recipe: "text-xs · font-normal", source: "ui/src/components/ui/badge.tsx", use: "12px normal in the shared Badge; filled/status variants use their own contrast colors. PropertyChip can override weight to regular." }, + { id: "tooltip", area: "Menus & previews", name: "Tooltip label", sample: "Toggle side panel", className: "w-fit rounded-md bg-foreground px-3 py-1.5 text-xs text-background", recipe: "text-xs · bg-foreground · text-background", source: "ui/src/components/ui/tooltip.tsx", use: "12px regular on an inverse surface. Distinct from the larger, structured task hover card." }, +]; diff --git a/ui/storybook/support/design-proposals/useButtonPreviewStates.ts b/ui/storybook/support/design-proposals/useButtonPreviewStates.ts new file mode 100644 index 00000000000..a3f40c3d261 --- /dev/null +++ b/ui/storybook/support/design-proposals/useButtonPreviewStates.ts @@ -0,0 +1,37 @@ +import { useEffect } from "react"; + +const pseudoState = /(? forcedRule(child, inheritedState || hasState)).join(""); + if (!declarations && !children) return ""; + return `${rule.selectorText.replace(pseudoState, '[data-button-preview-state="$1"]')}{${declarations}${children}}`; + } + if ("cssRules" in rule) { + const children = Array.from((rule as CSSGroupingRule).cssRules).map(child => forcedRule(child, inheritedState)).join(""); + return children ? `${rule.cssText.slice(0, rule.cssText.indexOf("{") + 1)}${children}}` : ""; + } + // Nested declarations inside an already selected state (modern CSS nesting). + return inheritedState && rule.constructor.name === "CSSNestedDeclarations" ? rule.cssText : ""; +} + +export function useButtonPreviewStates() { + useEffect(() => { + const style = document.createElement("style"); + style.dataset.buttonPreviewStates = ""; + style.textContent = Array.from(document.styleSheets).flatMap(sheet => { + try { return Array.from(sheet.cssRules).map(rule => forcedRule(rule)); } + catch { return []; } // External stylesheets may not expose their CSSOM. + }).join("\n"); + document.head.append(style); + return () => style.remove(); + }, []); +} diff --git a/ui/storybook/support/feature-map/FeatureOverview.tsx b/ui/storybook/support/feature-map/FeatureOverview.tsx index b4c8429a0be..c241bc3e10e 100644 --- a/ui/storybook/support/feature-map/FeatureOverview.tsx +++ b/ui/storybook/support/feature-map/FeatureOverview.tsx @@ -75,7 +75,9 @@ export function FeatureMapIndex() { ].join(" ").toLowerCase().includes(search); }), })).filter(section => section.features.length), [search]); - const foundations = examples.filter(entry => (entry.home === "foundations" || !entry.capabilities.length) + const proposals = examples.filter(entry => entry.home === "design-proposals" + && ["Design proposals", entry.file, entry.purpose, entry.notes].join(" ").toLowerCase().includes(search)); + const foundations = examples.filter(entry => entry.home !== "design-proposals" && (entry.home === "foundations" || !entry.capabilities.length) && ["Shared foundations", entry.file, entry.purpose, entry.status, entry.implementation, entry.boundary].join(" ").toLowerCase().includes(search)); return (
@@ -86,7 +88,15 @@ export function FeatureMapIndex() { Read the feature-map index setQuery(event.target.value)} /> - {sections.length === 0 && foundations.length === 0 ?

No matching features or examples.

: null} + {sections.length === 0 && foundations.length === 0 && proposals.length === 0 ?

No matching features or examples.

: null} + {proposals.length ? ( +
+

Design proposals

+

Design work awaiting review and app adoption. These examples do not count as current production coverage.

+ {error ?

Example links could not load. Reload to try again.

: null} +
    {proposals.map(entry => )}
+
+ ) : null} {sections.map(section => (

{section.title}