@talgent/workbench-ui 0.0.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +15 -2
  2. package/assets/brand-icons/LICENSE.md +30 -0
  3. package/assets/brand-icons/README.md +5 -0
  4. package/assets/brand-icons/figma.svg +1 -0
  5. package/assets/brand-icons/github.svg +1 -0
  6. package/assets/brand-icons/linear.svg +1 -0
  7. package/assets/brand-icons/notion.svg +1 -0
  8. package/assets/brand-icons/sentry.svg +1 -0
  9. package/assets/brand-icons/slack.svg +1 -0
  10. package/assets/fonts/Inter-OFL.txt +93 -0
  11. package/assets/fonts/inter-latin.woff2 +0 -0
  12. package/components/agent/agent-icon.css +14 -0
  13. package/components/agent/agent-icon.tsx +14 -0
  14. package/components/analytics/chart-geometry.ts +95 -0
  15. package/components/analytics/dashboard-charts.tsx +136 -0
  16. package/components/analytics/model-usage-chart.tsx +84 -0
  17. package/components/analytics/token-breakdown.css +13 -0
  18. package/components/analytics/token-breakdown.tsx +23 -0
  19. package/components/analytics/token-report.tsx +57 -0
  20. package/components/analytics/work-usage/turn-timeline.tsx +98 -0
  21. package/components/analytics/work-usage/turns.ts +170 -0
  22. package/components/analytics/work-usage/work-status.ts +36 -0
  23. package/components/analytics/work-usage/work-usage-reader.tsx +122 -0
  24. package/components/analytics/work-usage/work-usage.css +48 -0
  25. package/components/auth/auth-gate.tsx +44 -0
  26. package/components/chat/message-input.tsx +105 -0
  27. package/components/composer/permission-mode-list.tsx +15 -0
  28. package/components/composer/prompt-bar-controls.tsx +77 -0
  29. package/components/composer/prompt-bar.tsx +45 -0
  30. package/components/composer/skill-completion.css +27 -0
  31. package/components/composer/skill-completion.tsx +49 -0
  32. package/components/composer/use-slash-completion.tsx +69 -0
  33. package/components/composer/work-start.css +133 -0
  34. package/components/feedback/toast-region.css +2 -0
  35. package/components/feedback/toast-region.tsx +15 -0
  36. package/components/files/file-facts.tsx +16 -0
  37. package/components/files/file-section.tsx +181 -0
  38. package/components/markdown.tsx +144 -0
  39. package/components/marketplace/connector-pages.tsx +166 -0
  40. package/components/marketplace/marketplace-icons.tsx +66 -0
  41. package/components/marketplace/marketplace-shared.tsx +43 -0
  42. package/components/marketplace/marketplace-shell.tsx +32 -0
  43. package/components/marketplace/marketplace.css +97 -0
  44. package/components/marketplace/parameter-fields.tsx +46 -0
  45. package/components/marketplace/plugin-list.tsx +84 -0
  46. package/components/marketplace/plugin-page.tsx +133 -0
  47. package/components/marketplace/provider-pages.tsx +120 -0
  48. package/components/preview/file-format.ts +75 -0
  49. package/components/preview/file-preview.tsx +153 -0
  50. package/components/preview/preview-verdict.ts +44 -0
  51. package/components/preview/preview.css +19 -0
  52. package/components/preview/text-preview.tsx +174 -0
  53. package/components/profile/provider-callback.tsx +43 -0
  54. package/components/profile/work-agent-appearance.tsx +109 -0
  55. package/components/profile/work-agent-soul.tsx +76 -0
  56. package/components/rail/rail-section.tsx +20 -0
  57. package/components/rail/rail.css +19 -0
  58. package/components/rail/use-expand-on-change.ts +18 -0
  59. package/components/session/startup-progress.css +77 -0
  60. package/components/session/startup-progress.tsx +103 -0
  61. package/components/session/work-permission-control.tsx +45 -0
  62. package/components/session/work-thinking-control.tsx +52 -0
  63. package/components/shell/account-menu.tsx +134 -0
  64. package/components/shell/app-shell.tsx +148 -0
  65. package/components/shell/core-reading-views.tsx +26 -0
  66. package/components/shell/core-reading.tsx +17 -0
  67. package/components/shell/document-title.tsx +11 -0
  68. package/components/shell/entry-shell.ts +39 -0
  69. package/components/shell/file-reading.tsx +22 -0
  70. package/components/shell/page-layout.tsx +7 -0
  71. package/components/shell/reading-context.ts +41 -0
  72. package/components/shell/reading-panel.css +67 -0
  73. package/components/shell/reading-panel.tsx +74 -0
  74. package/components/shell/sidebar-parts.tsx +38 -0
  75. package/components/work/context-rail.css +61 -0
  76. package/components/work/conversation-unavailable.css +6 -0
  77. package/components/work/conversation-unavailable.tsx +11 -0
  78. package/components/work/subagent-item.tsx +42 -0
  79. package/components/work/subagents-section.css +31 -0
  80. package/components/work/subagents-section.tsx +40 -0
  81. package/components/work/token-usage.tsx +28 -0
  82. package/components/work/tool-message-detail.tsx +39 -0
  83. package/components/work/tool-output-preview.tsx +52 -0
  84. package/components/work/tool-preview-reading.tsx +27 -0
  85. package/components/work/tool-reading-content.tsx +31 -0
  86. package/components/work/work-context-rail.tsx +23 -0
  87. package/components/work/work-conversation.tsx +207 -0
  88. package/components/work/work-history.tsx +115 -0
  89. package/components/work/work-interaction.css +38 -0
  90. package/components/work/work-interaction.tsx +105 -0
  91. package/components/work/work-message-flow.css +86 -0
  92. package/components/work/work-message-flow.tsx +352 -0
  93. package/components/work/work-progress-section.tsx +20 -0
  94. package/components/work/work-prompt-bar.tsx +82 -0
  95. package/components/work/work-question-record.tsx +72 -0
  96. package/components/work/work-snapshot-state.tsx +24 -0
  97. package/components/work/work-status-control.tsx +54 -0
  98. package/components/work-start/start-work-launch-state.ts +33 -0
  99. package/components/work-start/work-start-catalog.tsx +116 -0
  100. package/components/work-start/work-start-form.tsx +173 -0
  101. package/components/work-start/work-start-tray.css +40 -0
  102. package/components/work-start/work-start-tray.tsx +128 -0
  103. package/hooks/use-debounced.ts +11 -0
  104. package/hooks/use-work-conversation.ts +125 -0
  105. package/hooks/use-work-lifecycle-controls.ts +40 -0
  106. package/hooks/use-work-stream.ts +91 -0
  107. package/lib/account-service.ts +77 -0
  108. package/lib/agent-appearance-service.ts +59 -0
  109. package/lib/agent-icon.ts +26 -0
  110. package/lib/agent-soul-service.ts +48 -0
  111. package/lib/agent-work-service.ts +23 -0
  112. package/lib/client.ts +20 -0
  113. package/lib/computer-release.ts +30 -0
  114. package/lib/computer-service.ts +241 -0
  115. package/lib/dashboard-report.ts +38 -0
  116. package/lib/dashboard.ts +21 -0
  117. package/lib/marketplace-catalog.ts +138 -0
  118. package/lib/marketplace-connections.ts +98 -0
  119. package/lib/marketplace-plugins.ts +53 -0
  120. package/lib/marketplace-routes.ts +12 -0
  121. package/lib/model-vendors.ts +13 -0
  122. package/lib/name-taken-error.ts +7 -0
  123. package/lib/notifications.ts +37 -0
  124. package/lib/permission-modes.ts +25 -0
  125. package/lib/personal-usage-service.ts +19 -0
  126. package/lib/personal-work-service.ts +159 -0
  127. package/lib/plugin-parameters.ts +64 -0
  128. package/lib/preview-verdict.ts +26 -0
  129. package/lib/relative-time.ts +11 -0
  130. package/lib/session.ts +108 -0
  131. package/lib/share-link.ts +4 -0
  132. package/lib/stale-build-reload.ts +41 -0
  133. package/lib/start-work-request.ts +34 -0
  134. package/lib/startup-progress.ts +9 -0
  135. package/lib/subagents.ts +12 -0
  136. package/lib/thinking-levels.ts +2 -0
  137. package/lib/token-analytics.ts +168 -0
  138. package/lib/token-usage.ts +64 -0
  139. package/lib/trace-view-model.ts +292 -0
  140. package/lib/transport.ts +29 -0
  141. package/lib/use-now.ts +11 -0
  142. package/lib/use-work-trace.ts +175 -0
  143. package/lib/user-display.ts +49 -0
  144. package/lib/work-conversation.ts +118 -0
  145. package/lib/work-file-service.ts +118 -0
  146. package/lib/work-model.ts +38 -0
  147. package/lib/work-notices.ts +23 -0
  148. package/lib/work-progress.ts +41 -0
  149. package/lib/work-projection.ts +122 -0
  150. package/lib/work-questions.ts +18 -0
  151. package/lib/work-start-draft.ts +61 -0
  152. package/lib/work-start-loadout.ts +100 -0
  153. package/lib/work-start-source.ts +16 -0
  154. package/lib/work-state-presentation.ts +80 -0
  155. package/lib/work-stream-source.ts +32 -0
  156. package/package.json +81 -5
  157. package/pages/computers/computers-page.tsx +56 -0
  158. package/pages/computers/connect-computer-page.tsx +92 -0
  159. package/pages/computers/install-commands.tsx +42 -0
  160. package/pages/computers/remove-computer-dialog.tsx +33 -0
  161. package/pages/computers/rename-computer-dialog.tsx +37 -0
  162. package/pages/computers/room-rules-dialog.tsx +55 -0
  163. package/pages/login-page.css +33 -0
  164. package/pages/login-page.tsx +88 -0
  165. package/pages/login-threads.tsx +51 -0
  166. package/pages/marketplace-page.tsx +59 -0
  167. package/pages/profile-page.tsx +68 -0
  168. package/pages/workbench/library-page.tsx +88 -0
  169. package/pages/workbench/new-work-page.tsx +24 -0
  170. package/pages/workbench/person.tsx +13 -0
  171. package/pages/workbench/personal-work-context.tsx +93 -0
  172. package/pages/workbench/personal-work-page.tsx +100 -0
  173. package/pages/workbench/personal-works-page.tsx +36 -0
  174. package/pages/workbench/share-dialog.tsx +57 -0
  175. package/pages/workbench/title-dialog.tsx +35 -0
  176. package/pages/workbench/usage-page.tsx +52 -0
  177. package/public/assets/brand/talgent-lettering.png +0 -0
  178. package/public/assets/brand/talgent-logo-page-bg.png +0 -0
  179. package/public/assets/brand/talgent-logo-white-bg.png +0 -0
  180. package/public/assets/brand/talgent-mark-page-bg.png +0 -0
  181. package/public/assets/brand/talgent-mark-white-bg.png +0 -0
  182. package/public/assets/brand/talgent-mark.png +0 -0
  183. package/public/assets/product-icons/LICENSE +21 -0
  184. package/public/assets/product-icons/README.md +13 -0
  185. package/public/assets/product-icons/anthropic.svg +1 -0
  186. package/public/assets/product-icons/deepseek.svg +1 -0
  187. package/public/assets/product-icons/glm.svg +1 -0
  188. package/public/assets/product-icons/kimi.svg +1 -0
  189. package/public/assets/product-icons/linear.svg +1 -0
  190. package/public/assets/product-icons/minimax.svg +1 -0
  191. package/public/assets/product-icons/notion.svg +1 -0
  192. package/public/assets/product-icons/openai.svg +1 -0
  193. package/public/assets/product-icons/qwen.svg +1 -0
  194. package/styles/base.css +95 -0
  195. package/styles/fonts.css +14 -0
  196. package/styles/markdown.css +79 -0
  197. package/styles/uber-theme.css +96 -0
  198. package/styles/work-composer.css +222 -0
  199. package/styles/work-stream.css +343 -0
  200. package/styles/workbench.css +144 -0
  201. package/styles/workspace.css +260 -0
  202. package/test-support/backend.ts +67 -0
  203. package/test-support/plugin-summary.ts +8 -0
  204. package/test-support/setup.ts +59 -0
  205. package/vite-preset.ts +85 -0
@@ -0,0 +1,19 @@
1
+ /* A context rail: a card of collapsible sections beside a page's main
2
+ content, shared by every page that has one. */
3
+ .cr-card { padding: 6px 18px; background: var(--surface); border: 1px solid color-mix(in oklch,var(--separator) 35%,transparent); border-radius: 22px; box-shadow: 0 4px 20px rgb(0 0 0 / 4%); max-height: calc(100dvh - 88px); overflow-y: auto; }
4
+ .cr-section { padding: 14px 0; min-width: 0; }
5
+ .cr-section~.cr-section { border-top: 1px solid color-mix(in oklch,var(--separator) 50%,transparent); }
6
+ .cr-heading { display: flex; align-items: center; justify-content: space-between; min-height: 24px; gap: 6px; }
7
+ .cr-heading h3 { flex: 1; min-width: 0; }
8
+ .cr-heading>.button { min-width: 24px; width: 24px; height: 24px; padding: 0; color: var(--muted); }
9
+ .cr-section h3,.cr-section-trigger { font-family: inherit; font-size: 14px; font-weight: 500; color: var(--muted); line-height: 22px; }
10
+ .cr-section-trigger { --button-bg: transparent; --button-bg-hover: transparent; --button-bg-pressed: transparent; width: 100%; display: flex; justify-content: space-between; gap: 8px; padding: 0; height: 24px; min-height: 24px; }
11
+ .cr-summary { margin-right: auto; color: color-mix(in oklch,var(--muted) 70%,transparent); font-size: 12px; font-variant-numeric: tabular-nums; }
12
+ .cr-expand { opacity: 0; flex-shrink: 0; }
13
+ .cr-section-trigger[aria-expanded="true"] .cr-expand { transform: rotate(90deg); }
14
+ .cr-section:hover .cr-expand,.cr-section:focus-within .cr-expand { opacity: 1; }
15
+ .cr-heading+div:not([hidden])>.cr-list { margin-top: 5px; }
16
+ .cr-list { list-style: none; margin: 0; padding: 0; }
17
+ @media (pointer: coarse) { .cr-expand { opacity: 1; } }
18
+ @media (max-width: 900px) { .cr-card { max-height: none; } }
19
+ .cr-notice { padding-block: 10px; font-size: 12px; }
@@ -0,0 +1,18 @@
1
+ import { useState } from "react";
2
+
3
+ // Whether a rail section is expanded, opening it when its content changes
4
+ // while the rail is in view. `content` names what the section shows, undefined
5
+ // until it is known; the first known content is where changes count from, and
6
+ // a new `scope` (another subject) starts over at the section's default.
7
+ export function useExpandOnChange({ content, scope, visible, defaultOpen }: { content: string | undefined; scope: string; visible: boolean; defaultOpen: boolean }) {
8
+ const [open, setOpen] = useState(defaultOpen);
9
+ const [seen, setSeen] = useState<{ scope: string; content: string | undefined }>({ scope, content });
10
+ if (seen.scope !== scope) {
11
+ setSeen({ scope, content });
12
+ setOpen(defaultOpen);
13
+ } else if (content !== undefined && content !== seen.content) {
14
+ setSeen({ scope, content });
15
+ if (seen.content !== undefined && visible) setOpen(true);
16
+ }
17
+ return [open, setOpen] as const;
18
+ }
@@ -0,0 +1,77 @@
1
+ /* Shared startup presentation, within the existing Work shell. */
2
+ .wlp-stage { --wlp-secondary: color-mix(in oklch, var(--foreground) 75%, var(--surface)); width: min(688px, 100%); margin: clamp(20px, 4vh, 40px) auto 20px; padding-inline: 24px; }
3
+ .wlp-panel { border: 1px solid var(--border); border-radius: 28px; padding: 24px 28px 0; background: var(--surface); box-shadow: 0 3px 8px oklch(0 0 0 / 0.02), 0 14px 40px -20px oklch(0 0 0 / 0.1); }
4
+ .wlp-heading { display: flex; align-items: center; gap: 24px; }
5
+ .wlp-symbol { display: grid; place-items: center; width: 56px; height: 56px; border-radius: 50%; background: var(--default); color: var(--foreground); flex-shrink: 0; }
6
+ .wlp-symbol > svg { width: 23px; height: 23px; stroke-width: 1.5; }
7
+ .wlp-heading h2 { font-size: 23px; font-weight: 500; line-height: 1.35; letter-spacing: -0.025em; }
8
+ .wlp-heading-copy { min-width: 0; flex: 1; }
9
+ .wlp-heading p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 5px; color: var(--wlp-secondary); font-size: 13px; line-height: 1.55; }
10
+ .wlp-elapsed { align-self: flex-start; margin-left: auto; padding-top: 5px; color: var(--wlp-secondary); font-size: 12px; font-variant-numeric: tabular-nums; }
11
+ .wlp-steps { list-style: none; margin: 22px 0 16px; padding: 0; }
12
+ .wlp-steps li { position: relative; display: flex; align-items: flex-start; gap: 14px; min-height: 38px; padding: 0 0 10px; }
13
+ .wlp-steps li:not(:last-child)::after { content: ""; position: absolute; width: 1px; background: var(--separator); left: 11px; top: 25px; bottom: 1px; }
14
+ .wlp-steps li:last-child { padding-bottom: 0; min-height: 24px; }
15
+ .wlp-step-marker { width: 24px; height: 24px; flex-shrink: 0; display: grid; place-items: center; border-radius: 50%; color: var(--wlp-secondary); }
16
+ .wlp-step-marker > span:empty { width: 6px; height: 6px; border: 1px solid var(--wlp-secondary); border-radius: 50%; opacity: 0.5; }
17
+ .wlp-step-copy { min-width: 0; flex: 1; padding-top: 2px; font-size: 14px; }
18
+ .wlp-step-copy p { color: var(--wlp-secondary); font-size: 12px; line-height: 1.6; margin-top: 4px; }
19
+ .wlp-steps [data-state="pending"] .wlp-step-copy { color: var(--wlp-secondary); }
20
+ .wlp-steps [data-state="active"] .wlp-step-copy > span { font-weight: 600; }
21
+ .wlp-steps [data-state="active"] .wlp-step-marker { background: var(--default); }
22
+ .wlp-steps [data-state="complete"] .wlp-step-marker { color: oklch(48% 0.055 160); background: oklch(96% 0.018 160); }
23
+ .wlp-steps [data-state="failed"] .wlp-step-marker { color: var(--danger); background: color-mix(in oklch, var(--danger) 8%, var(--surface)); }
24
+ .wlp-notice { margin: 0 0 14px; padding: 10px 12px; border-radius: 12px; font-size: 13px; line-height: 1.6; background: var(--default); }
25
+ .wlp-error { margin-bottom: 14px; gap: 8px; padding: 10px 12px; border-radius: 12px; box-shadow: none; }
26
+ .wlp-error.alert--danger { background: color-mix(in oklch, var(--danger) 8%, var(--surface)); }
27
+ .wlp-error .alert__description { font-size: 13px; line-height: 1.6; color: var(--foreground); }
28
+ .wlp-request { border-top: 1px solid var(--separator); }
29
+ .wlp-request summary { display: flex; align-items: center; gap: 10px; cursor: pointer; padding-block: 12px; font-size: 12px; list-style: none; }
30
+ .wlp-request summary::-webkit-details-marker { display: none; }
31
+ .wlp-request summary > span:nth-child(2) { margin-left: auto; color: var(--wlp-secondary); }
32
+ .wlp-request[open] summary > svg { transform: rotate(180deg); }
33
+ .wlp-request-content { padding: 0 0 20px; font-size: 13px; line-height: 1.7; }
34
+ .wlp-request-content > p { white-space: pre-wrap; overflow-wrap: anywhere; }
35
+ .wlp-request-content dl { display: grid; gap: 6px; margin-top: 16px; }
36
+ .wlp-request-content dl > div { display: flex; gap: 14px; }
37
+ .wlp-request-content dt { width: 90px; flex-shrink: 0; color: var(--wlp-secondary); }
38
+ .wlp-request-content dd { min-width: 0; overflow-wrap: anywhere; }
39
+ .wlp-footer { display: flex; align-items: center; gap: 12px; justify-content: space-between; margin: 0 -28px; padding: 10px 20px 10px 28px; border-radius: 0 0 28px 28px; background: color-mix(in oklch, var(--foreground) 3%, var(--surface)); }
40
+ .wlp-footer p { display: flex; align-items: center; gap: 6px; color: var(--wlp-secondary); font-size: 12px; }
41
+ .wlp-footer button { flex-shrink: 0; }
42
+ :is(.dark, [data-theme="dark"]) .wlp-steps [data-state="complete"] .wlp-step-marker { color: oklch(78% 0.055 160); background: oklch(28% 0.025 160); }
43
+ @media (max-width: 740px) {
44
+ .wlp-stage { padding-inline: 12px; margin-top: 16px; }
45
+ .wlp-panel { padding: 20px 18px 0; border-radius: 24px; }
46
+ .wlp-heading { align-items: flex-start; gap: 10px; flex-wrap: wrap; }
47
+ .wlp-symbol { width: 36px; height: 36px; border-radius: 12px; }
48
+ .wlp-heading > div { flex: 1; min-width: 0; }
49
+ .wlp-heading h2 { font-size: 20px; }
50
+ .wlp-elapsed { padding-top: 0; }
51
+ .wlp-steps li { gap: 10px; }
52
+ .wlp-request summary { flex-wrap: wrap; }
53
+ .wlp-request summary > span:nth-child(2) { margin-left: 0; font-size: 11px; }
54
+ .wlp-request summary > svg { margin-left: auto; }
55
+ .wlp-footer { margin-inline: -18px; padding: 10px 18px; border-radius: 0 0 24px 24px; flex-wrap: wrap; }
56
+ }
57
+
58
+ /* Ambient motion shows activity, never invented percentage progress. */
59
+ .wlp-panel { animation: wlp-enter 240ms cubic-bezier(0.2, 0, 0, 1) both; }
60
+ .wlp-symbol { position: relative; isolation: isolate; }
61
+ .wlp-symbol[data-running="true"]::before { content: ""; position: absolute; inset: -9px; border: 1px solid color-mix(in oklch, var(--foreground) 12%, transparent); border-radius: 50%; z-index: -1; animation: wlp-breathe 2800ms ease-in-out infinite; }
62
+ .wlp-symbol[data-running="true"] > svg { animation: wlp-glow 2800ms ease-in-out infinite; }
63
+ .wlp-heading-copy, .wlp-stage-detail { animation: wlp-enter 200ms cubic-bezier(0.2, 0, 0, 1) both; }
64
+ .wlp-steps [data-state="complete"] .wlp-step-marker { animation: wlp-complete 200ms cubic-bezier(0.2, 0, 0, 1) both; }
65
+ @keyframes wlp-enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
66
+ @keyframes wlp-breathe { 0%, 100% { opacity: 0.25; transform: scale(0.96); } 50% { opacity: 0.75; transform: scale(1.08); } }
67
+ @keyframes wlp-glow { 0%, 100% { opacity: 0.65; } 50% { opacity: 1; } }
68
+ @keyframes wlp-complete { from { opacity: 0; transform: scale(0.8); } to { opacity: 1; transform: scale(1); } }
69
+ .wlp-orbit { position: absolute; inset: -9px; border-radius: 50%; animation: wlp-orbit 4000ms linear infinite; }
70
+ .wlp-orbit::before { content: ""; position: absolute; width: 6px; height: 6px; left: calc(50% - 3px); top: -2px; background: var(--foreground); opacity: 0.6; border-radius: 50%; box-shadow: 0 0 8px color-mix(in oklch, var(--foreground) 12%, transparent); }
71
+ .wlp-activity { display: block; width: min(240px, 100%); height: 2px; margin-top: 8px; border-radius: 2px; overflow: hidden; background: var(--separator); }
72
+ .wlp-activity::after { content: ""; display: block; width: 25%; height: 100%; background: var(--wlp-secondary); border-radius: inherit; animation: wlp-flow 1800ms cubic-bezier(0.2, 0, 0, 1) infinite; }
73
+ @keyframes wlp-orbit { to { transform: rotate(360deg); } }
74
+ @keyframes wlp-flow { from { transform: translateX(-100%); } to { transform: translateX(400%); } }
75
+ @media (prefers-reduced-motion: reduce) {
76
+ .wlp-stage *, .wlp-stage *::before, .wlp-stage *::after { animation: none !important; transition: none !important; }
77
+ }
@@ -0,0 +1,103 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Alert, Button, Spinner } from "@heroui/react";
3
+ import { Check, ChevronDown, CircleAlert, Cloud, Monitor, RotateCcw, Square, X } from "lucide-react";
4
+ import type { StartupProgress } from "@talgent/workbench-contracts/agent/public/v1/startup_pb";
5
+ import type { WorkStartupRequest } from "@talgent/workbench-contracts/agent/public/v1/work_pb";
6
+ import type { WorkModelOption } from "@talgent/workbench-contracts/agent/public/v1/model_pb";
7
+ import { modelVendors } from "../../lib/model-vendors";
8
+ import type { WorkComputer } from "../../lib/computer-service";
9
+ import "./startup-progress.css";
10
+
11
+ const stages: Record<string, { name: string; detail: string }> = {
12
+ admission: { name: "Preparing to start", detail: "Checking availability for your Work." },
13
+ sandbox: { name: "Preparing computer", detail: "Bringing your cloud environment online." },
14
+
15
+ agent: { name: "Starting Agent", detail: "Loading the Agent and enabled plugins." },
16
+ workspace: { name: "Preparing workspace", detail: "Getting your working files and tools ready." },
17
+ input: { name: "Submitting task", detail: "Passing your instruction to the Agent." },
18
+ };
19
+ const statuses: Record<string, string> = { pending: "Waiting", active: "In progress", completed: "Ready", failed: "Failed", cancelled: "Cancelled" };
20
+ interface Props {
21
+ progress: StartupProgress | undefined;
22
+ workTitle?: string;
23
+ request?: WorkStartupRequest;
24
+ model?: WorkModelOption;
25
+ onRetry?: () => void;
26
+ onArchive?: () => void;
27
+ retryLabel?: string;
28
+ retryDescription?: string;
29
+ retryPending?: boolean;
30
+ retryError?: string;
31
+ /** The start waits in its Computer's line for room. */
32
+ waitingForRoom?: boolean;
33
+ /** The Computer the Work starts on; a Cloud when absent. */
34
+ computer?: WorkComputer;
35
+ }
36
+
37
+ export function StartupProgressPanel(props: Props) {
38
+ if (!props.progress || props.progress.status === "completed") return null;
39
+ return <StartupProgressView key={props.progress.requestId} {...props} progress={props.progress} />;
40
+ }
41
+
42
+ function StartupProgressView({ computer, progress, workTitle, request, model, onRetry, onArchive, retryLabel = "Retry startup", retryDescription, retryPending = false, retryError, waitingForRoom = false }: Props & { progress: StartupProgress }) {
43
+ const running = progress.status === "preparing" || progress.status === "recovering";
44
+ const failed = progress.status === "failed";
45
+ const cancelled = progress.status === "cancelled";
46
+ const [observedAt] = useState(() => Date.now());
47
+ const [elapsed, setElapsed] = useState(0);
48
+ useEffect(() => {
49
+ if (!running) return;
50
+ const timer = window.setInterval(() => setElapsed(Math.floor((Date.now() - observedAt) / 1000)), 1000);
51
+ return () => window.clearInterval(timer);
52
+ }, [observedAt, running]);
53
+ // A start waiting in its Computer's line has no preparation under way yet.
54
+ const queued = waitingForRoom && !failed && !cancelled;
55
+ const managed = computer?.managed ?? false;
56
+ const title = failed ? "Work couldn’t start" : cancelled ? "Startup cancelled" : queued ? "Waiting for room" : running ? "Starting your Work" : "Waiting for startup status";
57
+ const current = progress.stages.find(stage => stage.status === "active");
58
+ const elapsedText = `${String(Math.floor(elapsed / 60)).padStart(2, "0")}:${String(elapsed % 60).padStart(2, "0")}`;
59
+ return <section className="wlp-stage" aria-label="Startup progress">
60
+ <div className="wlp-panel" data-state={progress.status}>
61
+ <header className="wlp-heading">
62
+ <span className="wlp-symbol" data-running={running} aria-hidden="true">{running && <span className="wlp-orbit" />}{failed ? <CircleAlert /> : cancelled ? <Square /> : managed ? <Monitor /> : <Cloud />}</span>
63
+ <div key={title} className="wlp-heading-copy"><h2>{title}</h2>{workTitle && <p title={workTitle}>{workTitle}</p>}</div>
64
+ {running && <span className="wlp-elapsed" role="timer" title="Time observing this startup on this page" aria-label={`Observed on this page for ${elapsed} seconds`}>{elapsedText}</span>}
65
+ </header>
66
+ {!queued && <ol className="wlp-steps" aria-label="Startup stages">
67
+ {progress.stages.map(stage => {
68
+ const active = stage.status === "active" && running;
69
+ const done = stage.status === "completed";
70
+ const state = done ? "complete" : stage.status;
71
+ return <li key={stage.stage} data-state={state} aria-current={active || stage.status === "failed" ? "step" : undefined}>
72
+ <span key={state} className="wlp-step-marker" aria-hidden="true">{done ? <Check size={14} /> : active ? <Spinner size="sm" /> : stage.status === "failed" ? <X size={14} /> : <span />}</span>
73
+ <div className="wlp-step-copy"><span>{stages[stage.stage]?.name ?? stage.stage}</span>{active && <p className="wlp-stage-detail">{stageDetail(stage.stage, managed)}<span className="wlp-activity" aria-hidden="true" /></p>}</div>
74
+ <span className="sr-only">{statuses[stage.status] ?? "Waiting for status"}</span>
75
+ </li>;
76
+ })}
77
+ </ol>}
78
+ <div className="sr-only" role="status">{title}. {running && current && !queued ? stages[current.stage]?.name ?? current.stage : ""}</div>
79
+ {queued ? <p className="wlp-notice">It starts once a running Work on its Computer ends.</p>
80
+ : progress.status === "recovering" ? <p className="wlp-notice">A startup step is being retried automatically.</p>
81
+ : running && elapsed >= 45 && <p className="wlp-notice">This is taking longer than usual. Work is still preparing.</p>}
82
+ {(failed || cancelled) && progress.reason && <Alert className="wlp-error" status={failed ? "danger" : "default"} role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>{progress.reason}</Alert.Description></Alert.Content></Alert>}
83
+ {request && <details className="wlp-request"><summary><span>View request</span><span>{[model?.name, request.initialThinkingLevel].filter(Boolean).join(" · ")}</span><ChevronDown size={14} /></summary>
84
+ <div className="wlp-request-content"><p>{request.initialMessage}</p><dl>
85
+ <div><dt>Computer</dt><dd>{computer?.name ?? (managed ? "Managed Computer" : "Cloud")}</dd></div>
86
+ {model && <div><dt>Model</dt><dd>{model.name} · {modelVendors[model.vendor]?.label}</dd></div>}
87
+ <div><dt>Agent</dt><dd>{request.startupAgent || "Default Agent"}</dd></div>
88
+ {request.repositories.length > 0 && <div><dt>Repository</dt><dd>{request.repositories.map(repository => repository.name || repository.repositoryId).join(", ")}</dd></div>}
89
+ <div><dt>Plugins</dt><dd>{request.plugins.length ? request.plugins.join(", ") : "None"}</dd></div>
90
+ </dl></div>
91
+ </details>}
92
+ {retryError && <Alert className="wlp-error" status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>{retryError}</Alert.Description></Alert.Content></Alert>}
93
+ <footer className="wlp-footer"><p>{retryDescription ?? (running ? "Work starts in the background." : request ? "Your request is saved." : "Waiting for startup details.")}</p>{onArchive && <Button size="sm" variant="danger-soft" isDisabled={retryPending} onPress={onArchive}>Archive Work</Button>}{onRetry && <Button size="sm" isPending={retryPending} onPress={onRetry}><RotateCcw size={14} />{retryLabel}</Button>}</footer>
94
+ </div>
95
+ </section>;
96
+ }
97
+
98
+ // A Managed Computer is the person's own machine, already online when a start
99
+ // reaches it; its sandbox stage readies the Work there.
100
+ function stageDetail(stage: string, managed: boolean): string {
101
+ if (managed && stage === "sandbox") return "Getting your computer ready for this Work.";
102
+ return stages[stage]?.detail ?? "Waiting for the next startup update.";
103
+ }
@@ -0,0 +1,45 @@
1
+ import { useState } from "react";
2
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import { Button, Popover } from "@heroui/react";
4
+ import { ChevronDown, Lock, Shield } from "lucide-react";
5
+ import type { PermissionMode } from "@talgent/workbench-contracts/agent/public/v1/harness_pb";
6
+ import type { GetWorkResponse } from "@talgent/workbench-contracts/agent/public/v1/work_pb";
7
+ import { PermissionModeList } from "../composer/permission-mode-list";
8
+ import { PopoverFootnote } from "../composer/prompt-bar-controls";
9
+ import { agentWorkClient } from "../../lib/client";
10
+ import { agentWorkQueryKey } from "../../lib/agent-work-service";
11
+ import { permissionModeOption } from "../../lib/permission-modes";
12
+ import "../composer/work-start.css";
13
+
14
+ // A running Managed Work's permission mode, which only its starter may switch
15
+ // while it runs. A change shows at once and returns to the previous mode if
16
+ // the Harness refuses it; the Work then follows the mode its Harness reports,
17
+ // also one the Harness changed to by itself. Whether the Work runs in Claude
18
+ // Code's sandbox is fixed at start, so it is only stated.
19
+ export function WorkPermissionControl({ workId, mode, sandbox, canAdjust, starterOnly }: { workId: string; mode: PermissionMode; sandbox: boolean; canAdjust: boolean; starterOnly: boolean }) {
20
+ const [open, setOpen] = useState(false);
21
+ const cache = useQueryClient();
22
+ // The accepted mode until the Work itself reports a newer one.
23
+ const [accepted, setAccepted] = useState<{ mode: PermissionMode; basis: PermissionMode }>();
24
+ const change = useMutation({
25
+ mutationFn: async (next: PermissionMode) => (await agentWorkClient.setPermissionMode({ workId, permissionMode: next })).permissionMode,
26
+ onSuccess: next => {
27
+ setAccepted({ mode: next, basis: mode });
28
+ cache.setQueryData<GetWorkResponse>(agentWorkQueryKey(workId), data => data?.work ? { ...data, work: { ...data.work, permissionMode: next } } : data);
29
+ void cache.invalidateQueries({ queryKey: agentWorkQueryKey(workId) });
30
+ },
31
+ });
32
+ const shown = change.isPending ? change.variables ?? mode : accepted && accepted.basis === mode ? accepted.mode : mode;
33
+ const option = permissionModeOption(shown);
34
+ return <Popover isOpen={open} onOpenChange={value => { setOpen(value); if (value && !change.isPending) change.reset(); }}>
35
+ <Button size="sm" variant="ghost" aria-label="Permission mode" className="wpb-mode-trigger">
36
+ <Shield size={14} aria-hidden="true" /><span className="wpb-mode-name">{option?.short ?? "Permissions"}</span><ChevronDown size={13} />
37
+ </Button>
38
+ <Popover.Content placement="top end" className="wpb-popover wpb-popover-narrow"><Popover.Dialog aria-label="Permission mode">
39
+ <PermissionModeList value={shown} disabled={!canAdjust || change.isPending} onChange={next => change.mutate(next)} />
40
+ {change.error && <p className="wpb-popover-error" role="alert">Permission mode wasn’t changed: {change.error.message}</p>}
41
+ {starterOnly && <PopoverFootnote>Only the person who started this Work can change it</PopoverFootnote>}
42
+ {sandbox && <PopoverFootnote><span className="wpb-sandbox-note"><Lock size={12} aria-hidden="true" />Runs in Claude Code’s sandbox</span></PopoverFootnote>}
43
+ </Popover.Dialog></Popover.Content>
44
+ </Popover>;
45
+ }
@@ -0,0 +1,52 @@
1
+ import { useState } from "react";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { Button, Popover, Tooltip } from "@heroui/react";
4
+ import { Lock } from "lucide-react";
5
+ import type { WorkModelOption } from "@talgent/workbench-contracts/agent/public/v1/model_pb";
6
+ import { ModelTrigger, ModelVendorIcon, ThinkingEffort } from "../composer/prompt-bar-controls";
7
+ import { agentWorkClient } from "../../lib/client";
8
+ import { modelVendors } from "../../lib/model-vendors";
9
+ import { thinkingLevelName } from "../../lib/thinking-levels";
10
+ import "../composer/work-start.css";
11
+
12
+ type ThinkingLevel = { level: string };
13
+
14
+ // A running Work keeps its model, so the card presents it read
15
+ // only and adjusts only the thinking effort. The model comes from the Work
16
+ // itself; a withdrawn configuration has no option and offers no levels. The
17
+ // level is read from the Harness once and cached: a change shows at once and
18
+ // returns to the previous level if the Harness refuses it.
19
+ export function WorkThinkingControl({ sessionId, option, initialLevel = "", canAdjust }: { sessionId: string; option?: WorkModelOption; initialLevel?: string; canAdjust: boolean }) {
20
+ const [open, setOpen] = useState(false);
21
+ const cache = useQueryClient();
22
+ const key = ["work-thinking-level", sessionId];
23
+ const current = useQuery({ queryKey: key, queryFn: ({ signal }) => agentWorkClient.getThinkingLevel({ workId: sessionId }, { signal }), retry: false, staleTime: 60_000 });
24
+ const change = useMutation({
25
+ mutationFn: (level: string) => agentWorkClient.setThinkingLevel({ workId: sessionId, level }),
26
+ onMutate: async level => {
27
+ // A read still in flight must not overwrite the level just chosen.
28
+ await cache.cancelQueries({ queryKey: key });
29
+ const previous = cache.getQueryData<ThinkingLevel>(key);
30
+ cache.setQueryData<ThinkingLevel>(key, { level });
31
+ return { previous };
32
+ },
33
+ onError: (_error, _level, context) => cache.setQueryData(key, context?.previous),
34
+ });
35
+ const levels = option?.thinkingLevels ?? [];
36
+ const level = current.data?.level ?? (initialLevel || option?.defaultThinkingLevel || "");
37
+ const effort = current.isError ? "Unavailable" : option ? thinkingLevelName(level) : "";
38
+ // Opening refreshes a stale level in the background; the card never waits on it.
39
+ return <Popover isOpen={open} onOpenChange={value => { setOpen(value); if (!value) return; change.reset(); if (current.isStale && !change.isPending) void current.refetch(); }}>
40
+ <ModelTrigger name={option?.name ?? "Unavailable model"} effort={effort} tentative={current.isPending} />
41
+ <Popover.Content placement="top end" className="wpb-popover wpb-popover-narrow"><Popover.Dialog aria-label="Model settings">
42
+ <div className="wpb-model-head">
43
+ {option && <ModelVendorIcon vendor={option.vendor} />}
44
+ <span className="wpb-choice-copy"><span className="wpb-model-head-name">{option?.name ?? "Unavailable model"}</span>{option && <span className="wpb-muted">{modelVendors[option.vendor]?.label}</span>}</span>
45
+ <Tooltip delay={200}><Tooltip.Trigger className="wpb-lock" tabIndex={0} aria-label="Model is fixed for this Work"><Lock size={13} aria-hidden="true" /></Tooltip.Trigger><Tooltip.Content>Fixed for this Work</Tooltip.Content></Tooltip>
46
+ </div>
47
+ {levels.length > 0 && <ThinkingEffort levels={levels} value={level} disabled={!canAdjust || current.isError || change.isPending} onChange={next => change.mutate(next)} />}
48
+ {change.error && <p className="wpb-popover-error" role="alert">Thinking effort wasn’t changed: {change.error.message}</p>}
49
+ {current.isError && <p className="wpb-popover-error" role="alert">Thinking effort couldn’t be read. <Button size="sm" variant="ghost" onPress={() => void current.refetch()}>Retry</Button></p>}
50
+ </Popover.Dialog></Popover.Content>
51
+ </Popover>;
52
+ }
@@ -0,0 +1,134 @@
1
+ import { Avatar, Button, Dropdown, Header, Label, Separator, typographyVariants } from "@heroui/react";
2
+ import { AlertTriangle, Box, ChartColumn, Ellipsis, History, Library, LogOut, Monitor, Plus, Puzzle, UserRound } from "lucide-react";
3
+ import { useState, type ReactNode } from "react";
4
+ import { useCurrentUser, useLogout } from "../../lib/account-service";
5
+ import type { PersonalPaths } from "./entry-shell";
6
+ import { useInstalledConnectors, useInstalledPlugins, usePluginReleases } from "../../lib/marketplace-catalog";
7
+ import { serviceLabel } from "../../lib/marketplace-connections";
8
+ import { pluginDisplayName, pluginPresentation } from "../../lib/marketplace-plugins";
9
+ import { marketplacePaths } from "../../lib/marketplace-routes";
10
+ import { installationNeedsSetup } from "../../lib/plugin-parameters";
11
+ import { safeDisplayName, safeEmail } from "../../lib/user-display";
12
+ import { ConnectorIcon, PluginIcon } from "../marketplace/marketplace-icons";
13
+
14
+ // The account sits at the sidebar bottom and owns what is about the person:
15
+ // what they installed, their profile and signing out.
16
+ const installedShown = 8;
17
+
18
+ type InstalledItem = { id: string; label: string; to: string; icon: ReactNode; disabled: boolean; needsSetup: boolean };
19
+
20
+ // Installed Plugins (by the name shown), then Installed Connectors. Loads only
21
+ // once the person reaches for the menu, and reads the release of each installed
22
+ // Plugin (to name it and mark one needing setup), never the whole catalog.
23
+ function useInstalledItems(enabled: boolean) {
24
+ const plugins = useInstalledPlugins({ enabled });
25
+ const connectors = useInstalledConnectors({ enabled });
26
+ const installedPlugins = [...(plugins.data?.values() ?? [])];
27
+ const releases = usePluginReleases(installedPlugins.map(installed => installed.slug));
28
+ const pluginItems: InstalledItem[] = installedPlugins.map(installed => {
29
+ const release = releases.get(installed.slug);
30
+ return {
31
+ id: `plugin:${installed.slug}`,
32
+ label: release ? pluginPresentation(release).name : pluginDisplayName(installed.slug),
33
+ to: marketplacePaths.plugin(installed.slug),
34
+ icon: release ? <PluginIcon plugin={release} size={20} /> : <Puzzle size={16} />,
35
+ disabled: !installed.enabled,
36
+ needsSetup: Boolean(release && installationNeedsSetup(release.parameters, installed)),
37
+ };
38
+ });
39
+ const connectorItems: InstalledItem[] = (connectors.data ?? []).map(connector => ({
40
+ id: `connector:${connector.name}`,
41
+ label: serviceLabel(connector.name),
42
+ to: marketplacePaths.connector(connector.name),
43
+ icon: <ConnectorIcon name={connector.name} size={20} />,
44
+ disabled: connector.status === "disabled",
45
+ needsSetup: Boolean(connector.provider && connector.providerConnection !== "connected"),
46
+ }));
47
+ pluginItems.sort((a, b) => a.label.localeCompare(b.label));
48
+ return {
49
+ items: [...pluginItems, ...connectorItems],
50
+ isLoading: enabled && (plugins.isPending || connectors.isPending) && !(plugins.isError || connectors.isError),
51
+ isError: plugins.isError || connectors.isError,
52
+ retry: () => { void plugins.refetch(); void connectors.refetch(); },
53
+ };
54
+ }
55
+
56
+ const personalItems = [
57
+ { id: "personal-new", label: "New work", icon: Plus, to: (paths: PersonalPaths) => paths.newWork },
58
+ { id: "personal-works", label: "Personal works", icon: History, to: (paths: PersonalPaths) => paths.works },
59
+ { id: "personal-library", label: "Library", icon: Library, to: (paths: PersonalPaths) => paths.library },
60
+ { id: "personal-usage", label: "Usage", icon: ChartColumn, to: (paths: PersonalPaths) => paths.usage },
61
+ ] as const;
62
+
63
+ /** `personal` adds the person's own pages where the entry's navigation has
64
+ * no place for them. */
65
+ export function AccountMenu({ compact, onNavigate, personal }: { compact: boolean; onNavigate: (path: string) => void; personal?: PersonalPaths }) {
66
+ const user = useCurrentUser().data?.user;
67
+ const logout = useLogout();
68
+ // Hovering or focusing the trigger starts loading, so the menu rarely opens empty.
69
+ const [wanted, setWanted] = useState(false);
70
+ const { items: installed, isLoading, isError, retry } = useInstalledItems(wanted);
71
+ const email = safeEmail(user?.primaryEmail);
72
+ const name = safeDisplayName(user?.displayName, user?.id, email || "Profile");
73
+ const initial = name.trim().charAt(0).toUpperCase() || "P";
74
+ return (
75
+ <Dropdown onOpenChange={open => { if (open) setWanted(true); }}>
76
+ <Button variant="ghost" className={compact ? undefined : "p-sidebar-account"} isIconOnly={compact} aria-label={`Account menu: ${name}`}
77
+ onHoverStart={() => setWanted(true)} onFocus={() => setWanted(true)}>
78
+ <Avatar size="sm" className="size-6 shrink-0 rounded-full" aria-hidden="true">
79
+ {user?.avatarUrl && <Avatar.Image src={user.avatarUrl} alt="" />}
80
+ <Avatar.Fallback>{initial}</Avatar.Fallback>
81
+ </Avatar>
82
+ {!compact && <><span className={`${typographyVariants({ type: "body-sm", weight: "medium", truncate: true }).base()} p-sidebar-account-name`}>{name}</span><Ellipsis size={14} aria-hidden="true" /></>}
83
+ </Button>
84
+ <Dropdown.Popover placement="top start" className="p-sidebar-menu">
85
+ <Dropdown.Menu aria-label="Account" onAction={key => {
86
+ const own = personal && personalItems.find(item => item.id === key);
87
+ if (own) onNavigate(own.to(personal));
88
+ else if (key === "profile") onNavigate("/profile");
89
+ else if (key === "computers") onNavigate("/computers");
90
+ else if (key === "sign-out") logout.mutate();
91
+ else if (key === "installed-retry") retry();
92
+ else { const item = installed.find(entry => entry.id === key); if (item) onNavigate(item.to); }
93
+ }}>
94
+ {installed.length > 0 && <Dropdown.Section aria-label="Installed">
95
+ <Header>Installed</Header>
96
+ {installed.slice(0, installedShown).map(item => <InstalledEntry key={item.id} item={item} />)}
97
+ {/* The rest open beside the menu; the entry is marked when one of them needs attention. */}
98
+ {installed.length > installedShown && <Dropdown.SubmenuTrigger>
99
+ <Dropdown.Item id="all-installed" textValue={`All installed (${installed.length})`}><Box size={16} aria-hidden="true" /><Label>All installed ({installed.length})</Label>
100
+ {installed.slice(installedShown).some(item => item.needsSetup) && <AlertTriangle size={13} className="ml-auto shrink-0 text-warning" aria-label="Needs attention" />}
101
+ <Dropdown.SubmenuIndicator /></Dropdown.Item>
102
+ <Dropdown.Popover className="p-sidebar-menu max-h-[60vh] overflow-y-auto">
103
+ <Dropdown.Menu aria-label="All installed" onAction={key => { const item = installed.find(entry => entry.id === key); if (item) onNavigate(item.to); }}>
104
+ {installed.slice(installedShown).map(item => <InstalledEntry key={item.id} item={item} />)}
105
+ </Dropdown.Menu>
106
+ </Dropdown.Popover>
107
+ </Dropdown.SubmenuTrigger>}
108
+ </Dropdown.Section>}
109
+ {isLoading && <Dropdown.Item id="installed-loading" textValue="Loading installed" isDisabled><Label>Loading installed…</Label></Dropdown.Item>}
110
+ {isError && <Dropdown.Item id="installed-retry" textValue="Installed couldn’t be loaded. Retry"><AlertTriangle size={16} className="text-warning" aria-hidden="true" /><Label>Installed couldn’t be loaded. Retry</Label></Dropdown.Item>}
111
+ {(installed.length > 0 || isLoading || isError) && <Separator />}
112
+ {personal && <Dropdown.Section aria-label="Personal">
113
+ <Header>Personal</Header>
114
+ {personalItems.map(({ id, label, icon: Icon }) => <Dropdown.Item key={id} id={id} textValue={label}><Icon size={16} aria-hidden="true" /><Label>{label}</Label></Dropdown.Item>)}
115
+ </Dropdown.Section>}
116
+ {personal && <Separator />}
117
+ <Dropdown.Item id="profile" textValue="Edit profile"><UserRound size={16} aria-hidden="true" /><Label>Edit profile</Label></Dropdown.Item>
118
+ <Dropdown.Item id="computers" textValue="Computers"><Monitor size={16} aria-hidden="true" /><Label>Computers</Label></Dropdown.Item>
119
+ <Separator />
120
+ <Dropdown.Item id="sign-out" textValue="Sign out" isDisabled={logout.isPending}><LogOut size={16} aria-hidden="true" /><Label>Sign out</Label></Dropdown.Item>
121
+ </Dropdown.Menu>
122
+ </Dropdown.Popover>
123
+ </Dropdown>
124
+ );
125
+ }
126
+
127
+ function InstalledEntry({ item }: { item: InstalledItem }) {
128
+ return <Dropdown.Item id={item.id} textValue={item.label} className={item.disabled ? "opacity-50" : undefined}>
129
+ <span className="flex size-5 shrink-0 items-center justify-center" aria-hidden="true">{item.icon}</span>
130
+ <Label>{item.label}</Label>
131
+ {item.disabled && <span className="sr-only">Disabled</span>}
132
+ {item.needsSetup && <AlertTriangle size={13} className="ml-auto shrink-0 text-warning" aria-label="Needs attention" />}
133
+ </Dropdown.Item>;
134
+ }
@@ -0,0 +1,148 @@
1
+ import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { Breadcrumbs, Button, Card, Drawer } from "@heroui/react";
3
+ import { Menu } from "lucide-react";
4
+ import { useLocation, useNavigate } from "react-router-dom";
5
+ import { coreReading } from "./core-reading";
6
+ import { ReadingContext, type ReadingContent, type ReadingRenderer } from "./reading-context";
7
+ import { ReadingPanel } from "./reading-panel";
8
+
9
+ /** What every entry's sidebar is given by the shell. */
10
+ export type ShellSidebarProps = {
11
+ compact: boolean;
12
+ mobile: boolean;
13
+ isReading: boolean;
14
+ onNavigate: (path: string) => void;
15
+ onCollapsedChange: (collapsed: boolean) => void;
16
+ onCloseMobile: () => void;
17
+ };
18
+
19
+ /** One level of the page location; the last one is the current page. */
20
+ export type Crumb = { label: string; to?: string; onPress?: () => void };
21
+
22
+ export interface AppShellProps {
23
+ children: ReactNode;
24
+ sidebar: (props: ShellSidebarProps) => ReactNode;
25
+ /** The page's name for assistive technology. */
26
+ title: string;
27
+ /** What precedes the location, such as the Project switcher. */
28
+ lead?: ReactNode;
29
+ /** The page location; absent where the sidebar entry already names the page. */
30
+ crumbs?: Crumb[];
31
+ /** Centered in the top bar, such as a perspective switch. */
32
+ center?: ReactNode;
33
+ topActions?: ReactNode;
34
+ hideTopline?: boolean;
35
+ width?: "contained" | "fluid";
36
+ /** Shown above the page, such as a closed Project's banner. */
37
+ banner?: ReactNode;
38
+ /** Mounted beside the page, such as the shell's dialogs. */
39
+ overlays?: ReactNode;
40
+ /** The entry's own readings, tried before the core's. */
41
+ reading?: ReadingRenderer;
42
+ }
43
+
44
+ const sidebarStorageKey = "talgent.shell.sidebarCollapsed";
45
+
46
+ /** The page frame every entry shares: the collapsible sidebar and its mobile
47
+ * drawer, the top bar with location and actions, the page, and the reading
48
+ * area that splits off beside it. */
49
+ export function AppShell({ children, sidebar, title, lead, crumbs, center, topActions, hideTopline = false, width = "fluid", banner, overlays, reading: entryReading }: AppShellProps) {
50
+ const [mobileOpen, setMobileOpen] = useState(false);
51
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(readStoredSidebarCollapsed);
52
+ const location = useLocation();
53
+ const navigate = useNavigate();
54
+ const widthClass = width === "fluid" ? "p-page w-full max-w-none" : "p-page mx-auto w-full max-w-[1400px]";
55
+ const routeScope = `${location.pathname}${location.search}`;
56
+ const [selection, setSelection] = useState<{ scope: string; content: ReadingContent } | null>(null);
57
+ const reading = selection?.scope === routeScope ? selection.content : null;
58
+ const view = reading ? entryReading?.(reading) ?? coreReading(reading) : null;
59
+ const [fullscreen, setFullscreen] = useState(false);
60
+ const [narrowReader, setNarrowReader] = useState(() => window.matchMedia?.("(max-width: 740px)").matches ?? false);
61
+ useEffect(() => {
62
+ const media = window.matchMedia?.("(max-width: 740px)");
63
+ if (!media) return;
64
+ const update = () => setNarrowReader(media.matches);
65
+ media.addEventListener("change", update);
66
+ return () => media.removeEventListener("change", update);
67
+ }, []);
68
+ const [split, setSplit] = useState(1 / 3);
69
+ const [container, setContainer] = useState<HTMLElement | null>(null);
70
+ const [body, setBody] = useState<HTMLElement | null>(null);
71
+ const [contextToolbar, setContextToolbar] = useState<HTMLDivElement | null>(null);
72
+ const [contextToggleToolbar, setContextToggleToolbar] = useState<HTMLDivElement | null>(null);
73
+ const [workStatusToolbar, setWorkStatusToolbar] = useState<HTMLDivElement | null>(null);
74
+ const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
75
+ const returnFocus = useRef<HTMLElement | null>(null);
76
+ const openReading = (content: ReadingContent, trigger?: HTMLElement) => {
77
+ returnFocus.current = trigger ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
78
+ setSelection({ scope: routeScope, content });
79
+ setFullscreen(false);
80
+ };
81
+ const closeReading = useCallback(() => {
82
+ setSelection(null); setFullscreen(false);
83
+ requestAnimationFrame(() => {
84
+ if (returnFocus.current?.isConnected) returnFocus.current.focus();
85
+ else toolbar?.querySelector<HTMLElement>("button")?.focus();
86
+ });
87
+ }, [toolbar, setFullscreen, setSelection]);
88
+ const effectiveCollapsed = Boolean(view) || sidebarCollapsed;
89
+ const sidebarState = effectiveCollapsed ? "collapsed" : "expanded";
90
+
91
+ useEffect(() => {
92
+ writeStoredSidebarCollapsed(sidebarCollapsed);
93
+ }, [sidebarCollapsed]);
94
+
95
+ const go = (path: string) => { navigate(path); setMobileOpen(false); };
96
+ const renderSidebar = (compact: boolean, mobile = false) => sidebar({ compact, mobile, isReading: Boolean(view), onNavigate: go, onCollapsedChange: setSidebarCollapsed, onCloseMobile: () => setMobileOpen(false) });
97
+ const crumbLink = (crumb: Crumb) => crumb.to ? { href: crumb.to, onClick: (event: { preventDefault: () => void }) => { event.preventDefault(); navigate(crumb.to!); } }
98
+ : crumb.onPress ? { href: "#", onClick: (event: { preventDefault: () => void }) => { event.preventDefault(); crumb.onPress!(); } } : {};
99
+
100
+ return (
101
+ <ReadingContext.Provider value={{ content: view ? reading : null, open: openReading, close: closeReading, workStatusToolbar, contextToolbar, contextToggleToolbar, toolbar }}><main ref={setContainer} className="p-app" data-reading={Boolean(view)} data-sidebar-state={sidebarState} style={{ "--pr-main-share": `${split}fr`, "--pr-reader-share": `${1 - split}fr` } as CSSProperties}>
102
+ <aside className="p-sidebar" inert={Boolean(view && (fullscreen || narrowReader))} data-collapsed={effectiveCollapsed} data-sidebar-state={sidebarState} data-sidebar-tone="light" data-testid="app-sidebar" aria-label="Workspace navigation">
103
+ <Card variant="transparent" className="p-sidebar-shell">{renderSidebar(effectiveCollapsed)}</Card>
104
+ </aside>
105
+ <Drawer.Backdrop isOpen={mobileOpen} onOpenChange={setMobileOpen}>
106
+ <Drawer.Content placement="left" className="p-sidebar-drawer"><Drawer.Dialog aria-label="Workspace navigation"><div className="p-drawer-navigation">{renderSidebar(false, true)}</div></Drawer.Dialog></Drawer.Content>
107
+ </Drawer.Backdrop>
108
+ <section ref={setBody} className="p-body" inert={Boolean(view && (fullscreen || narrowReader))}>
109
+ {hideTopline ? null : (
110
+ <header className={`p-topbar ${center ? "p-dashboard-topbar" : ""}`} data-testid="app-title-header">
111
+ <div className="p-breadcrumb">
112
+ <Button variant="ghost" isIconOnly className="p-mobile-toggle" aria-label="Open navigation" onPress={() => setMobileOpen(true)}><Menu size={20} /></Button>
113
+ <h1 className="sr-only">{title}</h1>
114
+ {lead}
115
+ {crumbs && <Breadcrumbs aria-label="Page location" className="p-location">
116
+ {crumbs.map((crumb, index) => <Breadcrumbs.Item key={`${index}:${crumb.label}`} {...(index < crumbs.length - 1 ? crumbLink(crumb) : {})}>{crumb.label}</Breadcrumbs.Item>)}
117
+ </Breadcrumbs>}
118
+ </div>
119
+ {center}
120
+ <div className="p-actions p-page-actions" data-testid="app-title-actions">{topActions}<div className="p-context-actions" ref={setToolbar} /><div className="p-context-actions" ref={setWorkStatusToolbar} /><div className="p-context-actions" ref={setContextToolbar} /><div className="p-context-actions" ref={setContextToggleToolbar} /></div>
121
+ </header>
122
+ )}
123
+ <div data-testid="app-layout-main" data-layout-width={width} data-sidebar-state={sidebarState} className={widthClass}>
124
+ {banner}
125
+ {children}
126
+ </div>
127
+ </section>
128
+ {overlays}
129
+ {reading && view && <ReadingPanel key={view.key} view={view} onClose={closeReading} fullscreen={fullscreen} onFullscreenChange={setFullscreen} split={split} onSplitChange={setSplit} body={body} container={container} />}
130
+ </main></ReadingContext.Provider>
131
+ );
132
+ }
133
+
134
+ function readStoredSidebarCollapsed() {
135
+ try {
136
+ return window.localStorage.getItem(sidebarStorageKey) === "true";
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ function writeStoredSidebarCollapsed(collapsed: boolean) {
143
+ try {
144
+ window.localStorage.setItem(sidebarStorageKey, collapsed ? "true" : "false");
145
+ } catch {
146
+ // localStorage can be unavailable in privacy-restricted browsers.
147
+ }
148
+ }