@talgent/workbench-ui 0.0.0 → 0.2.0

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,105 @@
1
+ import { Alert, Button } from "@heroui/react";
2
+ import { useCallback, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
3
+ import { ArrowUp, Square } from "lucide-react";
4
+ import { PromptBar } from "../composer/prompt-bar";
5
+ import { PromptAddMenu } from "../composer/prompt-bar-controls";
6
+ import { SkillHighlight } from "../composer/skill-completion";
7
+ import { useSlashCompletion, type SlashCandidate } from "../composer/use-slash-completion";
8
+
9
+ export interface MessageInputProps {
10
+ workId?: string;
11
+ /** Set while a Turn runs: an empty draft's primary action cancels the Turn,
12
+ * and a written draft is sent into it as Steer. */
13
+ onCancelTurn?: () => Promise<unknown> | unknown;
14
+ context?: ReactNode;
15
+ disabled: boolean;
16
+ sendDisabled?: boolean;
17
+ mode?: "send" | "disabled";
18
+ onSend: (text: string) => Promise<unknown> | unknown;
19
+ onUploadAttachment?: (file: File) => Promise<unknown> | unknown;
20
+ workActionPending?: boolean;
21
+ workActionError?: string;
22
+ placeholder?: string;
23
+ /** Why the composer takes no input while `mode` is "disabled". */
24
+ unavailableText?: string;
25
+ sendLabel?: string;
26
+ inputLabel?: string;
27
+ modelControl?: ReactNode;
28
+ /** Commands and Skills the Harness reported; observed is false before its first report. */
29
+ slash?: { candidates: SlashCandidate[]; observed: boolean };
30
+ /** Regional notices shown above the composer, such as Plugin failures. */
31
+ notices?: ReactNode;
32
+ }
33
+
34
+ export function MessageInput({ onCancelTurn, context, disabled, sendDisabled = false, mode = "send", onSend, onUploadAttachment, workActionPending = false, workActionError = "", placeholder = "Work on anything", unavailableText = "Work is not ready for input…", sendLabel = "Send", inputLabel = "Instruction", modelControl, slash, notices }: MessageInputProps) {
35
+ const [text, setText] = useState("");
36
+ const [sending, setSending] = useState(false);
37
+ const [cancelling, setCancelling] = useState(false);
38
+ const [sendError, setSendError] = useState("");
39
+ const [uploading, setUploading] = useState(false);
40
+ const [uploadError, setUploadError] = useState("");
41
+ const sendInFlight = useRef(false);
42
+ const cancelInFlight = useRef(false);
43
+ const fileInput = useRef<HTMLInputElement>(null);
44
+ const textarea = useRef<HTMLTextAreaElement>(null);
45
+ const uploadDisabled = disabled || sending || !onUploadAttachment || uploading;
46
+ const listId = useId();
47
+ const completion = useSlashCompletion(text, setText, slash?.candidates ?? [], `${listId}-commands`);
48
+ // One primary action: with nothing written during a Turn it cancels the Turn.
49
+ const cancelShown = Boolean(onCancelTurn) && !text.trim();
50
+ const primaryDisabled = cancelShown ? disabled || cancelling || workActionPending : disabled || sendDisabled || sending || workActionPending || mode === "disabled" || !text.trim();
51
+ const send = async () => {
52
+ if (primaryDisabled || cancelShown || sendInFlight.current) return;
53
+ const instruction = text.trim();
54
+ if (!instruction) return;
55
+ // The Harness is authoritative: a command it didn't report can't run, so the
56
+ // message is kept for editing instead of being sent.
57
+ const command = slash?.observed ? /^\/([\w:.-]+)(?=\s|$)/.exec(instruction)?.[1] : undefined;
58
+ if (command && !slash!.candidates.some(item => item.name === command)) {
59
+ setSendError(`/${command} isn’t a command or Skill in this Work, so it wasn’t sent. Your message is kept so you can change it.`);
60
+ textarea.current?.focus();
61
+ return;
62
+ }
63
+ sendInFlight.current = true; setSending(true); setSendError("");
64
+ try { await onSend(instruction); setText(""); }
65
+ catch (error) { setSendError(error instanceof Error ? error.message : "Message could not be sent. Try again."); }
66
+ finally { sendInFlight.current = false; setSending(false); }
67
+ };
68
+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
69
+ if (slash && completion.onKeyDown(event)) return;
70
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing && event.keyCode !== 229) { event.preventDefault(); void send(); }
71
+ };
72
+ const attach = useCallback(() => {
73
+ if (!disabled && !sending && onUploadAttachment && !uploading) fileInput.current?.click();
74
+ }, [disabled, sending, onUploadAttachment, uploading]);
75
+ // "Commands & Skills" starts a "/" token at the end of the draft, which opens the slash menu.
76
+ const openCommands = () => { setText(current => !current || /\s$/.test(current) ? `${current}/` : `${current} /`); setTimeout(() => textarea.current?.focus()); };
77
+ const commandsAvailable = Boolean(slash) && !disabled && !sending && mode !== "disabled";
78
+ const leading = <div className="composer-tool-row">
79
+ <PromptAddMenu disabled={uploadDisabled && !commandsAvailable} attachDisabled={uploadDisabled} onAttach={attach} onCommands={commandsAvailable ? openCommands : undefined} />
80
+ <input ref={fileInput} type="file" className="sr-only" aria-label="Upload attachment file" disabled={uploadDisabled} onChange={async event => {
81
+ const file = event.target.files?.[0]; event.target.value = "";
82
+ if (!file || uploadDisabled || !onUploadAttachment) return;
83
+ setUploading(true); setUploadError("");
84
+ try { await onUploadAttachment(file); }
85
+ catch (error) { setUploadError(error instanceof Error ? error.message : "Attachment upload failed."); }
86
+ finally { setUploading(false); }
87
+ }} />
88
+ </div>;
89
+ return <div className="wpb-conversation">
90
+ {notices}
91
+ {[...new Set([sendError, uploadError, workActionError].filter(Boolean))].map(error => <Alert key={error} status="danger" role="alert" className="mb-2"><Alert.Indicator /><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>)}
92
+ {slash && completion.menu && <div className="sk-menu-float mb-2">{completion.menu}</div>}
93
+ <PromptBar inline context={<>{slash && <SkillHighlight text={text} candidates={slash.candidates} />}{context}</>} input={{ ref: textarea, value: text, ...(slash ? completion.inputProps : {}), onChange: event => { setText(event.target.value); if (sendError) setSendError(""); }, onKeyDown, disabled: disabled || sending || mode === "disabled", placeholder: mode === "disabled" ? unavailableText : placeholder, "aria-label": inputLabel }} leading={leading}>
94
+ {modelControl}
95
+ <Button size="sm" isIconOnly className="wpb-send" isDisabled={primaryDisabled} aria-label={cancelShown ? "Cancel turn" : mode === "disabled" ? "Work unavailable" : sendLabel} onPress={async () => {
96
+ if (!cancelShown || !onCancelTurn) { void send(); return; }
97
+ if (cancelInFlight.current || primaryDisabled) return;
98
+ cancelInFlight.current = true; setCancelling(true); setSendError("");
99
+ try { await onCancelTurn(); }
100
+ catch (error) { setSendError(error instanceof Error ? error.message : "Could not cancel this turn."); }
101
+ finally { cancelInFlight.current = false; setCancelling(false); }
102
+ }}>{cancelShown ? <Square size={14} fill="currentColor" /> : <ArrowUp size={19} />}</Button>
103
+ </PromptBar>
104
+ </div>;
105
+ }
@@ -0,0 +1,15 @@
1
+ import { Label, ListBox } from "@heroui/react";
2
+ import type { PermissionMode } from "@talgent/workbench-contracts/agent/public/v1/harness_pb";
3
+ import { permissionModes } from "../../lib/permission-modes";
4
+
5
+ /** The permission modes as one single choice, each with what it does. Both
6
+ * composers use it: Start Work on a Managed Computer and a running Managed Work. */
7
+ export function PermissionModeList({ value, disabled, onChange }: { value: PermissionMode; disabled?: boolean; onChange: (mode: PermissionMode) => void }) {
8
+ return <ListBox aria-label="Permission mode" selectionMode="single" disallowEmptySelection selectedKeys={[String(value)]}
9
+ disabledKeys={disabled ? permissionModes.map(option => String(option.mode)) : []}
10
+ onSelectionChange={keys => { if (keys === "all" || !keys.size) return; const next = Number([...keys][0]) as PermissionMode; if (next !== value) onChange(next); }}>
11
+ {permissionModes.map(option => <ListBox.Item key={option.mode} id={String(option.mode)} textValue={option.name} className="wpb-choice">
12
+ <span className="wpb-choice-copy wpb-choice-stack"><Label>{option.name}</Label><span className="wpb-muted">{option.description}</span></span><ListBox.ItemIndicator />
13
+ </ListBox.Item>)}
14
+ </ListBox>;
15
+ }
@@ -0,0 +1,77 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { Button, Dropdown, Kbd, Label, ListBox, Popover, SearchField, ToggleButton, ToggleButtonGroup } from "@heroui/react";
3
+ import { Brain, BrainCircuit, ChevronDown, Gauge, Paperclip, Plus, SquareSlash, Zap } from "lucide-react";
4
+ import type { ModelVendor } from "@talgent/workbench-contracts/agent/public/v1/model_pb";
5
+ import { modelVendors } from "../../lib/model-vendors";
6
+ import { thinkingLevelName as levelName } from "../../lib/thinking-levels";
7
+ import { startModelKey, type StartModel } from "../../lib/work-model";
8
+
9
+ const levelIcon = (level: string) => level === "low" ? Zap : level === "medium" ? Gauge : level === "high" ? Brain : BrainCircuit;
10
+
11
+ // A model list searches only once it is longer than a glance.
12
+ const searchableModels = 6;
13
+
14
+ export function ModelVendorIcon({ vendor }: { vendor: ModelVendor }) {
15
+ const icon = modelVendors[vendor]?.icon;
16
+ return icon ? <img className="wpb-product-icon" src={`/assets/product-icons/${icon}.svg`} alt="" /> : <Brain size={16} aria-hidden="true" />;
17
+ }
18
+
19
+ /** The composer's model trigger: the model's name and the thinking effort, or a status in its place. */
20
+ export function ModelTrigger({ name, effort, tentative = false, disabled }: { name: string; effort: string; tentative?: boolean; disabled?: boolean }) {
21
+ return <Button size="sm" variant="ghost" aria-label="Model and thinking effort" className="wpb-model-trigger" isDisabled={disabled}>
22
+ <span className="wpb-model-name">{name}</span><span className="wpb-trigger-effort" data-tentative={tentative || undefined}>{effort}</span><ChevronDown size={13} />
23
+ </Button>;
24
+ }
25
+
26
+ /** One segmented choice of the model's thinking levels. */
27
+ export function ThinkingEffort({ levels, value, disabled, onChange }: { levels: string[]; value: string; disabled?: boolean; onChange: (level: string) => void }) {
28
+ return <div className="wpb-effort">
29
+ <p id="wpb-effort-label">Thinking effort</p>
30
+ <ToggleButtonGroup aria-labelledby="wpb-effort-label" selectionMode="single" disallowEmptySelection fullWidth size="sm" isDisabled={disabled}
31
+ selectedKeys={value ? new Set([value]) : new Set()} onSelectionChange={keys => { const next = [...keys][0]; if (next !== undefined && String(next) !== value) onChange(String(next)); }}>
32
+ {levels.map((level, index) => { const Icon = levelIcon(level); return <ToggleButton key={level} id={level}>{index > 0 && <ToggleButtonGroup.Separator />}<Icon size={14} aria-hidden="true" />{levelName(level)}</ToggleButton>; })}
33
+ </ToggleButtonGroup>
34
+ </div>;
35
+ }
36
+
37
+ // Start Work chooses a model and its thinking effort; a running Work's model
38
+ // is fixed and uses its own card.
39
+ export function PromptModelControl({ models, model, thinking, disabled, loading, open, onOpenChange, onChange }: {
40
+ models: StartModel[]; model?: StartModel; thinking: string; disabled?: boolean; loading?: boolean;
41
+ open: boolean; onOpenChange: (open: boolean) => void; onChange: (model: StartModel, thinking: string) => void;
42
+ }) {
43
+ const [query, setQuery] = useState("");
44
+ const shown = models.filter(item => `${item.name} ${modelVendors[item.vendor]?.label ?? ""}`.toLowerCase().includes(query.trim().toLowerCase()));
45
+ return <Popover isOpen={open} onOpenChange={value => { onOpenChange(value); if (value) setQuery(""); }}>
46
+ <ModelTrigger name={model?.name ?? (loading ? "" : "Select model")} effort={model ? levelName(thinking) : ""} disabled={disabled} />
47
+ <Popover.Content placement="bottom end" className="wpb-popover wpb-popover-narrow"><Popover.Dialog aria-label="Model settings">
48
+ {models.length > searchableModels && <SearchField className="wpb-search" aria-label="Search models" value={query} onChange={setQuery} onKeyDown={event => { if (event.key === "Enter") event.preventDefault(); }}>
49
+ <SearchField.Group><SearchField.SearchIcon /><SearchField.Input autoFocus placeholder="Search models" /><SearchField.ClearButton aria-label="Clear search" /></SearchField.Group>
50
+ </SearchField>}
51
+ <ListBox aria-label="Models" selectionMode="single" disallowEmptySelection selectedKeys={model ? [startModelKey(model)] : []} renderEmptyState={() => <p className="wpb-empty" role="status">No matches</p>}
52
+ onSelectionChange={keys => { if (keys === "all") return; const next = models.find(item => keys.has(startModelKey(item))); if (next) onChange(next, next.thinkingLevels.includes(thinking) ? thinking : next.defaultThinkingLevel); }}>
53
+ {shown.map(item => <ListBox.Item id={startModelKey(item)} key={startModelKey(item)} textValue={item.name} className="wpb-choice"><ModelVendorIcon vendor={item.vendor} /><span className="wpb-choice-copy"><Label>{item.name}</Label><span className="wpb-muted">{modelVendors[item.vendor]?.label}</span></span><ListBox.ItemIndicator /></ListBox.Item>)}
54
+ </ListBox>
55
+ {model && model.thinkingLevels.length > 0 && <ThinkingEffort levels={model.thinkingLevels} value={thinking} onChange={level => onChange(model, level)} />}
56
+ </Popover.Dialog></Popover.Content>
57
+ </Popover>;
58
+ }
59
+
60
+ /** The composer's "+": attach files where the Work takes them, or open the
61
+ * slash menu of commands and Skills. */
62
+ export function PromptAddMenu({ disabled, attachDisabled, onAttach, onCommands }: { disabled?: boolean; attachDisabled?: boolean; onAttach?: () => void; onCommands?: () => void }) {
63
+ return <Dropdown>
64
+ <Button size="sm" variant="ghost" className="wpb-add" isIconOnly isDisabled={disabled} aria-label="Add context and options"><Plus size={22} strokeWidth={1.75} /></Button>
65
+ <Dropdown.Popover placement="top start" className="wpb-popover wpb-popover-narrow">
66
+ <Dropdown.Menu aria-label="Add to message" onAction={key => key === "attach" ? onAttach?.() : onCommands?.()} disabledKeys={[...(attachDisabled ? ["attach"] : []), ...(onCommands ? [] : ["commands"])]}>
67
+ {onAttach && <Dropdown.Item id="attach" textValue="Attach files"><Paperclip size={16} aria-hidden="true" /><Label>Attach files</Label></Dropdown.Item>}
68
+ <Dropdown.Item id="commands" textValue="Commands and Skills"><SquareSlash size={16} aria-hidden="true" /><Label>Commands & Skills</Label><Kbd className="wpb-kbd">/</Kbd></Dropdown.Item>
69
+ </Dropdown.Menu>
70
+ </Dropdown.Popover>
71
+ </Dropdown>;
72
+ }
73
+
74
+ /** A popover's closing note, such as the scope of the changes made in it. */
75
+ export function PopoverFootnote({ children }: { children: ReactNode }) {
76
+ return <p className="wpb-footnote">{children}</p>;
77
+ }
@@ -0,0 +1,45 @@
1
+ import { Card, TextArea } from "@heroui/react";
2
+ import { useImperativeHandle, useLayoutEffect, useRef, type ComponentProps, type ReactNode } from "react";
3
+ import "./work-start.css";
4
+
5
+ interface PromptBarProps {
6
+ input: ComponentProps<typeof TextArea>;
7
+ leading: ReactNode;
8
+ children: ReactNode;
9
+ context?: ReactNode;
10
+ inline?: boolean;
11
+ }
12
+
13
+ export function PromptBar({ input, leading, children, context, inline = false }: PromptBarProps) {
14
+ const textarea = useRef<HTMLTextAreaElement>(null);
15
+ useImperativeHandle(input.ref, () => textarea.current!);
16
+ useLayoutEffect(() => {
17
+ if (!inline || !textarea.current) return;
18
+ const field = textarea.current;
19
+ const bar = field.closest<HTMLElement>(".wpb-bar")!;
20
+ const resize = () => {
21
+ bar.dataset.expanded = "false";
22
+ field.style.overflowY = "hidden";
23
+ field.style.height = "auto";
24
+ const lineHeight = Number.parseFloat(getComputedStyle(field).lineHeight);
25
+ bar.dataset.expanded = String(Boolean(field.value) && field.scrollHeight > Math.ceil(lineHeight));
26
+ // scrollHeight rounds fractional line heights; leave room for the last line.
27
+ const height = field.value ? field.scrollHeight + 1 : Math.ceil(lineHeight);
28
+ field.style.height = `${Math.min(height, 220)}px`;
29
+ field.style.overflowY = height > 220 ? "auto" : "hidden";
30
+ };
31
+ resize();
32
+ let width = bar.clientWidth;
33
+ const observer = new ResizeObserver(() => {
34
+ if (bar.clientWidth !== width) { width = bar.clientWidth; resize(); }
35
+ });
36
+ observer.observe(bar);
37
+ return () => observer.disconnect();
38
+ }, [inline, input.value]);
39
+
40
+ return <Card className={`wpb-bar${inline ? " wpb-bar-compact" : ""}`}>
41
+ <TextArea {...input} ref={textarea} className="wpb-input resize-none" rows={inline ? 1 : 3} />
42
+ {context}
43
+ <div className="wpb-toolbar">{leading}{children}</div>
44
+ </Card>;
45
+ }
@@ -0,0 +1,27 @@
1
+ /* Slash completion and in-text Skill highlight, shared by Start Work and
2
+ the running composer. */
3
+ :root { --sk-skill-hue: oklch(55% 0.075 210); }
4
+ .sk-muted { color: var(--muted); }
5
+ .sk-slash { display: grid; gap: 1px; max-height: min(360px, 45dvh); overflow: hidden auto; }
6
+ .sk-slash-row { display: grid; grid-template-columns: 16px minmax(0, max-content) minmax(0, 1fr) 14px; gap: 10px; align-items: center; padding: 7px 10px; border-radius: 10px; font-size: 13px; cursor: pointer; }
7
+ .sk-slash-row[aria-selected="true"] { background: color-mix(in oklch, var(--foreground) 6%, transparent); }
8
+ .sk-slash-name { overflow: hidden; text-overflow: ellipsis; font-weight: 500; white-space: nowrap; }
9
+ .sk-slash-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
10
+ .sk-slash-heading { position: sticky; top: 0; z-index: 1; display: flex; justify-content: space-between; padding: 8px 10px 4px; font-size: 11px; font-weight: 500; color: var(--muted); background: var(--surface); }
11
+ .sk-slash-heading span { font-variant-numeric: tabular-nums; font-weight: 400; }
12
+ .sk-slash-empty { padding: 8px 10px; font-size: 13px; color: var(--muted); }
13
+ /* One floating surface in both composers: above a running Work's composer, under Start Work's. */
14
+ .sk-menu-float { display: grid; padding: 6px; border-radius: 16px; background: var(--surface); border: 1px solid var(--separator); box-shadow: 0 10px 30px -12px oklch(0 0 0 / 0.15); }
15
+ .sk-menu-under { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 20; }
16
+
17
+ /* The textarea draws only the caret; the mirror draws the text. */
18
+ textarea[data-skill-highlight="true"] { position: relative; z-index: 1; color: transparent !important; caret-color: var(--foreground); background: transparent; }
19
+ textarea[data-skill-highlight="true"]::placeholder { color: var(--field-placeholder, var(--muted)); }
20
+ textarea[data-skill-highlight="true"]::selection { color: transparent; background: color-mix(in oklch, var(--accent) 22%, transparent); }
21
+ .sk-mirror { position: absolute; overflow: hidden; white-space: pre-wrap; overflow-wrap: break-word; color: var(--foreground); pointer-events: none; box-sizing: border-box; }
22
+ .sk-mark { color: color-mix(in oklch, var(--sk-skill-hue) 85%, var(--foreground)); background: color-mix(in oklch, var(--sk-skill-hue) 14%, transparent); border-radius: 6px; box-decoration-break: clone; -webkit-box-decoration-break: clone; }
23
+ .sk-ghost { color: var(--muted); opacity: 0.7; }
24
+ @media (max-width: 740px) {
25
+ .sk-slash-row { grid-template-columns: 16px minmax(0, 1fr) 14px; }
26
+ .sk-slash-desc { display: none; }
27
+ }
@@ -0,0 +1,49 @@
1
+ import { useLayoutEffect, useRef } from "react";
2
+ import type { SlashCandidate } from "./use-slash-completion";
3
+ import "./skill-completion.css";
4
+
5
+ const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
+
7
+ // Renders the text behind a transparent-text textarea so a selected Skill reads
8
+ // as a highlighted token in the sentence, with its argument hint as ghost text
9
+ // right after a freshly inserted Skill. The textarea stays the only input; the
10
+ // mirror copies its box and typography so the two stay aligned in every layout.
11
+ const mirrored = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "textIndent"] as const;
12
+ export function SkillHighlight({ text, candidates }: { text: string; candidates: SlashCandidate[] }) {
13
+ const mirror = useRef<HTMLDivElement>(null);
14
+ const names = candidates.filter(item => !item.builtIn).map(item => item.name);
15
+ const active = names.length > 0;
16
+ useLayoutEffect(() => {
17
+ // The textarea is the mirror's sibling inside the same prompt bar.
18
+ const layer = mirror.current, field = layer?.parentElement?.querySelector("textarea");
19
+ if (!active || !field || !layer) return;
20
+ field.dataset.skillHighlight = "true";
21
+ const sync = () => {
22
+ const style = getComputedStyle(field);
23
+ for (const property of mirrored) layer.style[property] = style[property];
24
+ Object.assign(layer.style, { top: `${field.offsetTop}px`, left: `${field.offsetLeft}px`, width: `${field.clientWidth}px`, height: `${field.clientHeight}px` });
25
+ layer.scrollTop = field.scrollTop;
26
+ };
27
+ sync();
28
+ field.addEventListener("scroll", sync);
29
+ const observer = new ResizeObserver(sync);
30
+ observer.observe(field);
31
+ return () => { field.removeEventListener("scroll", sync); observer.disconnect(); delete field.dataset.skillHighlight; };
32
+ }, [active, text]);
33
+ if (!active) return null;
34
+ const pattern = new RegExp(`(^|\\s)(/(?:${names.map(escape).join("|")}))(?=\\s|$)`, "g");
35
+ const parts: Array<{ text: string; skill: boolean }> = [];
36
+ let last = 0;
37
+ for (const match of text.matchAll(pattern)) {
38
+ const start = match.index + match[1].length;
39
+ parts.push({ text: text.slice(last, start), skill: false }, { text: match[2], skill: true });
40
+ last = start + match[2].length;
41
+ }
42
+ parts.push({ text: text.slice(last), skill: false });
43
+ const fresh = /\/([\w:.-]+) $/.exec(text)?.[1];
44
+ const hint = fresh ? candidates.find(item => item.name === fresh && !item.builtIn)?.argumentHint : "";
45
+ return <div ref={mirror} className="sk-mirror" aria-hidden="true">
46
+ {parts.map((part, index) => part.skill ? <mark key={index} className="sk-mark">{part.text}</mark> : <span key={index}>{part.text}</span>)}
47
+ {hint && <span className="sk-ghost">{hint}</span>}{"​"}
48
+ </div>;
49
+ }
@@ -0,0 +1,69 @@
1
+ import { useEffect, useRef, useState, type KeyboardEvent } from "react";
2
+ import { CornerDownLeft, Sparkles } from "lucide-react";
3
+ import "./skill-completion.css";
4
+
5
+ // A command or Skill offered after "/". Start Work offers the Skills declared by
6
+ // the selected Plugins, grouped by Plugin; a running Work offers what its
7
+ // Harness reported, which carries no reliable category.
8
+ export interface SlashCandidate { name: string; description: string; argumentHint: string; group?: string; builtIn?: boolean }
9
+
10
+ const tokenOf = (value: string) => /(?:^|\s)\/([\w:.-]*)$/.exec(value)?.[1];
11
+ const recentLimit = 3;
12
+
13
+ /**
14
+ * Completes the "/" token at the end of the draft. Every match is listed and
15
+ * scrolls; the composer's recently used entries come first while nothing is
16
+ * typed after the slash. `grouped` sections the rest by `group`.
17
+ */
18
+ export function useSlashCompletion(value: string, setValue: (value: string) => void, candidates: SlashCandidate[], listId: string, grouped = false) {
19
+ const [active, setActive] = useState(0);
20
+ const [dismissed, setDismissed] = useState("");
21
+ const [recent, setRecent] = useState<string[]>([]);
22
+ const list = useRef<HTMLDivElement>(null);
23
+ const token = tokenOf(value);
24
+ const open = token !== undefined && dismissed !== value;
25
+ const query = token?.toLowerCase() ?? "";
26
+ const filtered = open ? candidates.filter(item => `${item.name} ${item.description}`.toLowerCase().includes(query)) : [];
27
+ const recentItems = query ? [] : recent.map(name => filtered.find(item => item.name === name)).filter((item): item is SlashCandidate => Boolean(item));
28
+ const rest = filtered.filter(item => !recentItems.includes(item));
29
+ const sections = [
30
+ ...(recentItems.length ? [{ title: "Recent", items: recentItems }] : []),
31
+ ...(grouped ? [...new Set(rest.map(item => item.group ?? ""))].map(title => ({ title, items: rest.filter(item => (item.group ?? "") === title) }))
32
+ : [{ title: recentItems.length ? "All" : "", items: rest }]),
33
+ ].filter(section => section.items.length);
34
+ const matches = sections.flatMap(section => section.items);
35
+ const current = Math.min(active, Math.max(matches.length - 1, 0));
36
+ // Keeps the active option in view; environments without layout (jsdom) lack scrollIntoView.
37
+ useEffect(() => { if (open) list.current?.querySelector(`[data-index="${current}"]`)?.scrollIntoView?.({ block: "nearest" }); }, [open, current]);
38
+ const pick = (item: SlashCandidate) => {
39
+ setValue(value.replace(/\/[\w:.-]*$/, `/${item.name} `)); setActive(0);
40
+ setRecent(names => [item.name, ...names.filter(name => name !== item.name)].slice(0, recentLimit));
41
+ };
42
+ const onKeyDown = (event: KeyboardEvent) => {
43
+ if (!open || !matches.length) return false;
44
+ if (event.key === "ArrowDown") { event.preventDefault(); setActive((current + 1) % matches.length); return true; }
45
+ if (event.key === "ArrowUp") { event.preventDefault(); setActive((current - 1 + matches.length) % matches.length); return true; }
46
+ if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); pick(matches[current]); return true; }
47
+ if (event.key === "Escape") { event.preventDefault(); setDismissed(value); return true; }
48
+ return false;
49
+ };
50
+ let index = 0;
51
+ const row = (item: SlashCandidate) => {
52
+ const position = index++;
53
+ return <div key={item.name} id={`${listId}-${position}`} data-index={position} role="option" aria-selected={position === current} className="sk-slash-row"
54
+ onMouseDown={event => { event.preventDefault(); pick(item); }} onMouseEnter={() => setActive(position)}>
55
+ <Sparkles size={15} className="sk-muted" aria-hidden="true" />
56
+ <span className="sk-slash-name">/{item.name}{item.argumentHint && <span className="sk-muted"> {item.argumentHint}</span>}</span>
57
+ <span className="sk-muted sk-slash-desc">{item.description}</span>
58
+ {position === current && <CornerDownLeft size={13} className="sk-muted" aria-hidden="true" />}
59
+ </div>;
60
+ };
61
+ const menu = open ? <div ref={list} className="sk-slash" role="listbox" id={listId} aria-label="Commands and Skills">
62
+ {matches.length ? sections.map(section => section.title
63
+ ? <div key={section.title} role="group" aria-label={section.title}><div className="sk-slash-heading" aria-hidden="true">{section.title}<span>{section.items.length}</span></div>{section.items.map(row)}</div>
64
+ : section.items.map(row)) : <p className="sk-slash-empty">No command or Skill matches “/{token}”.</p>}
65
+ </div> : null;
66
+ // Wired to the textarea so assistive technology follows the active option.
67
+ const inputProps = open && matches.length ? { "aria-controls": listId, "aria-activedescendant": `${listId}-${current}`, "aria-autocomplete": "list" as const } : {};
68
+ return { menu, onKeyDown, inputProps };
69
+ }
@@ -0,0 +1,133 @@
1
+ /* Prompt Bar proportions follow the supplied reference; controls use shared theme tokens. */
2
+ :root { --wpb-panel-width: 812px; --wpb-panel-gutter: 24px; }
3
+ .wpb-stage, .wpb-modal { width: min(calc(var(--wpb-panel-width) + 2 * var(--wpb-panel-gutter)), 100%); padding-inline: var(--wpb-panel-gutter); }
4
+ .wpb-stage { margin: clamp(64px, 15vh, 144px) auto 40px; }
5
+ .wpb-stage h2 { font-size: 26px; font-weight: 500; line-height: 1.4; letter-spacing: -0.02em; text-align: center; margin: 0 0 34px; }
6
+ .wpb-heading { display: flex; flex-direction: column; align-items: center; gap: 6px; overflow-wrap: anywhere; }
7
+ .wpb-heading-key { color: var(--muted); font-size: 12px; font-weight: 500; letter-spacing: 0; font-variant-numeric: tabular-nums; }
8
+ .wpb-form { min-width: 0; width: 100%; }
9
+ .wpb-bar { position: relative; z-index: 1; gap: 0; padding: 0; border-radius: 28px; border: 1px solid color-mix(in oklch, var(--border), var(--surface) 35%); box-shadow: 0 2px 4px oklch(0 0 0 / 0.025), 0 10px 36px -12px oklch(0 0 0 / 0.09); }
10
+ .wpb-input { display: block; width: 100%; min-height: 102px; max-height: 220px; padding: 20px 22px 14px; border: 0; border-radius: inherit; background: transparent; color: var(--foreground); font: inherit; font-size: 16px; line-height: 1.65; resize: none; }
11
+ .wpb-input:focus { outline: none; box-shadow: none; }
12
+ .wpb-input { box-shadow: none; }
13
+ .wpb-bar:focus-within { border-color: var(--border); }
14
+ .wpb-toolbar { display: flex; align-items: center; gap: 8px; padding: 2px 12px 12px 14px; }
15
+ .wpb-add { flex-shrink: 0; width: 36px; height: 36px; border-radius: 50%; }
16
+ .wpb-add svg { width: 22px; height: 22px; }
17
+ .wpb-model-trigger { margin-left: auto; max-width: calc(100% - 88px); gap: 7px; font-size: 14px; font-weight: 400; }
18
+ .wpb-trigger-effort { color: var(--muted); font-size: 14px; }
19
+ .wpb-trigger-effort[data-tentative] { opacity: 0.55; }
20
+ .wpb-send { flex-shrink: 0; width: 36px; height: 36px; border-radius: 50%; }
21
+ .wpb-context { display: flex; gap: 6px; flex-wrap: wrap; padding: 0 20px 12px; }
22
+ .wpb-context > button { max-width: 100%; }
23
+ .wpb-filename { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
24
+ .wpb-search { flex: 1; min-width: 0; }
25
+ .wpb-search .search-field__input { min-width: 0; }
26
+ .wpb-search .search-field__group { border: 0; box-shadow: none; background: transparent; }
27
+ .wpb-empty { padding: 16px 10px; color: var(--muted); font-size: 13px; }
28
+ .wpb-modal { max-width: min(calc(var(--wpb-panel-width) + 2 * var(--wpb-panel-gutter)), var(--wpb-available-width, 100%)); flex: none; padding-block: 48px; }
29
+ .wpb-dialog { max-width: none; padding: 0; background: transparent; box-shadow: none; overflow: visible; }
30
+ .wpb-close { top: -38px; right: 0; border-radius: 50%; background: var(--surface); }
31
+ .wpb-tray { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; margin: -20px 20px 0; padding: 24px 10px 6px; border-radius: 0 0 20px 20px; background: color-mix(in oklch, var(--foreground) 7%, var(--surface)); }
32
+ .wpb-config-trigger { min-width: 0; max-width: 100%; gap: 8px; padding-inline: 10px; height: 34px; color: color-mix(in oklch, var(--foreground) 80%, var(--surface)); font-size: 13px; font-weight: 400; border-radius: 8px; }
33
+ .wpb-config-trigger:hover, .wpb-config-trigger[aria-expanded="true"] { color: var(--foreground); background: color-mix(in oklch, var(--foreground) 6%, transparent); }
34
+ .wpb-config-trigger > svg { flex-shrink: 0; stroke-width: 1.6px; }
35
+ .wpb-config-trigger > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
36
+ @media (max-width: 740px) {
37
+ .wpb-tray { margin-inline: 12px; padding-inline: 6px; gap: 2px; }
38
+ .wpb-config-trigger { padding-inline: 7px; gap: 6px; }
39
+ :root { --wpb-panel-gutter: 12px; }
40
+ .wpb-stage { margin-top: 48px; }
41
+ .wpb-stage h2 { font-size: 22px; margin-bottom: 24px; }
42
+ .wpb-input { padding: 18px; font-size: 16px; }
43
+ .wpb-toolbar { padding: 2px 10px 10px; gap: 4px; }
44
+ .wpb-model-trigger { gap: 5px; font-size: 13px; padding-inline: 6px; }
45
+ .wpb-trigger-effort { font-size: 12px; }
46
+ }
47
+ @media (max-width: 360px) {
48
+ .wpb-trigger-effort { display: none; }
49
+ }
50
+
51
+ /* Category accents stay on icons; labels and selection marks remain neutral. */
52
+ .wpb-form, .wpb-popover {
53
+ --wpb-icon-environment: oklch(55% 0.07 245);
54
+ --wpb-icon-context: oklch(56% 0.075 60);
55
+ --wpb-icon-agent: oklch(56% 0.075 300);
56
+ --wpb-icon-capability: oklch(54% 0.055 175);
57
+ }
58
+ :is(.dark, [data-theme="dark"]) :is(.wpb-form, .wpb-popover) {
59
+ --wpb-icon-environment: oklch(75% 0.06 245);
60
+ --wpb-icon-context: oklch(77% 0.065 60);
61
+ --wpb-icon-agent: oklch(76% 0.065 300);
62
+ --wpb-icon-capability: oklch(74% 0.045 175);
63
+ }
64
+ :is(.wpb-form, .wpb-popover) :is(.lucide-cloud, .lucide-monitor, .lucide-server, .lucide-shield) { color: var(--wpb-icon-environment); }
65
+ :is(.wpb-form, .wpb-popover) :is(.lucide-git-branch, .lucide-paperclip, .lucide-plug) { color: var(--wpb-icon-context); }
66
+ :is(.wpb-form, .wpb-popover) :is(.lucide-bot, .lucide-zap, .lucide-brain, .lucide-brain-circuit) { color: var(--wpb-icon-agent); }
67
+ :is(.wpb-form, .wpb-popover) :is(.lucide-puzzle, .lucide-sparkles, .lucide-store, .lucide-folder) { color: var(--wpb-icon-capability); }
68
+ /* The slash menu reads the same in both composers, so its icons stay neutral here too. */
69
+ :is(.wpb-form, .wpb-popover) .sk-slash svg { color: var(--muted); }
70
+
71
+ .wpb-product-icon { flex: 0 0 16px; width: 16px; height: 16px; object-fit: contain; }
72
+ :is(.dark, [data-theme="dark"]) .wpb-product-icon { filter: invert(1); }
73
+
74
+
75
+ .wpb-model-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
76
+ .wpb-model-trigger > :not(.wpb-model-name) { flex-shrink: 0; }
77
+ .wpb-feedback { margin: 12px 20px 0; font-size: 13px; color: var(--muted); }
78
+ .wpb-feedback-error { color: var(--danger); }
79
+
80
+ /* The same input and bottom toolbar expand upward as the conversation draft grows. */
81
+ .wpb-conversation { flex-shrink: 0; width: min(var(--wpb-panel-width), 100%); margin-inline: auto; padding-block: 8px 16px; }
82
+ .wpb-bar-compact > .wpb-input { min-height: 0; }
83
+ .wpb-bar-compact[data-expanded="false"] { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 8px; padding: 10px 12px; }
84
+ .wpb-bar-compact[data-expanded="false"] > .wpb-input { grid-column: 2; grid-row: 1; min-width: 0; padding: 0; border-radius: 0; }
85
+ .wpb-bar-compact[data-expanded="false"] > .wpb-toolbar { display: contents; }
86
+ .wpb-bar-compact[data-expanded="false"] .wpb-toolbar > :first-child { grid-column: 1; grid-row: 1; }
87
+ .wpb-bar-compact[data-expanded="false"] .wpb-toolbar > .wpb-model-trigger { grid-column: 3; grid-row: 1; margin: 0; max-width: min(230px, 30vw); }
88
+ .wpb-bar-compact[data-expanded="false"] .wpb-toolbar > .composer-primary-actions { grid-column: 4; grid-row: 1; }
89
+ /* A Managed Work shows its permission mode beside the model, as one toolbar item. */
90
+ .wpb-work-controls { display: flex; align-items: center; gap: 2px; min-width: 0; margin-left: auto; }
91
+ .wpb-work-controls > .wpb-model-trigger { margin-left: 0; min-width: 0; }
92
+ .wpb-bar-compact[data-expanded="false"] .wpb-toolbar > .wpb-work-controls { grid-column: 3; grid-row: 1; margin: 0; max-width: min(340px, 42vw); }
93
+ .wpb-mode-trigger { flex-shrink: 0; gap: 6px; padding-inline: 8px; color: var(--muted); font-size: 14px; font-weight: 400; }
94
+ .wpb-sandbox-note { display: inline-flex; align-items: center; gap: 6px; }
95
+ @media (max-width: 740px) {
96
+ .wpb-mode-trigger { font-size: 13px; padding-inline: 6px; }
97
+ .wpb-mode-name { display: none; }
98
+ }
99
+ @media (max-width: 740px) {
100
+ .wpb-bar-compact[data-expanded="false"] { gap: 4px; padding-inline: 8px; }
101
+ }
102
+
103
+
104
+ /* The field inside the bar is borderless, so the keyboard focus ring belongs to the bar. */
105
+ .wpb-bar:has(.wpb-input[data-focus-visible="true"]) { outline: 2px solid var(--focus); outline-offset: 2px; }
106
+
107
+ /* Every Prompt Bar card — model, "+", tray — shares the slash menu's floating surface and one row anatomy. */
108
+ .wpb-popover { max-width: calc(100vw - 24px); border-radius: 16px; border: 1px solid var(--separator); box-shadow: 0 10px 30px -12px oklch(0 0 0 / 0.15); }
109
+ .wpb-popover [data-slot="popover-dialog"] { padding: 6px; max-height: min(520px, 70dvh); overflow-y: auto; }
110
+ .wpb-popover-narrow { width: 264px; }
111
+ .wpb-popover-tray { width: 340px; }
112
+ .wpb-popover-wide { width: 560px; }
113
+ .wpb-muted { color: var(--muted); }
114
+ .wpb-search .search-field__group:focus-within { outline: none; background: var(--default); }
115
+ .wpb-choice { display: flex; align-items: center; gap: 10px; min-height: 40px; }
116
+ .wpb-choice > svg:first-child, .wpb-choice > img:first-child { flex: 0 0 16px; width: 16px; height: 16px; stroke-width: 1.6px; }
117
+ .wpb-choice > .list-box-item__indicator { flex-shrink: 0; margin-left: auto; }
118
+ .wpb-choice-copy { display: flex; align-items: baseline; gap: 8px; flex: 1; min-width: 0; }
119
+ .wpb-choice-copy .label, .wpb-model-head-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
120
+ .wpb-choice-copy .wpb-muted { flex-shrink: 0; font-size: 12px; }
121
+ .wpb-choice-stack { display: grid; gap: 1px; padding-block: 4px; }
122
+ .wpb-choice-stack .wpb-muted { line-height: 1.35; }
123
+ .wpb-model-head { display: flex; align-items: center; gap: 10px; padding: 8px 8px 10px; }
124
+ .wpb-model-head > img, .wpb-model-head > svg { flex: 0 0 16px; }
125
+ .wpb-model-head-name { font-size: 14px; font-weight: 500; }
126
+ .wpb-lock { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: 6px; color: var(--muted); }
127
+ .wpb-lock:focus-visible { outline: 2px solid var(--focus); }
128
+ .wpb-effort { padding: 10px 8px 6px; border-top: 1px solid var(--separator); margin-top: 4px; }
129
+ .wpb-effort > p { margin: 0 0 8px; font-size: 12px; color: var(--muted); }
130
+ .wpb-effort .toggle-button { gap: 5px; }
131
+ .wpb-popover-error { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 6px 8px 4px; font-size: 12px; color: var(--danger); }
132
+ .wpb-footnote { margin: 6px 0 0; padding: 8px 10px 2px; border-top: 1px solid var(--separator); font-size: 11px; color: var(--muted); }
133
+ .wpb-kbd { margin-left: auto; }
@@ -0,0 +1,2 @@
1
+ /* The notification region sits below the shared topbar and above page content. */
2
+ .app-toasts { top: calc(var(--topbar-height) + 8px) !important; z-index: 80 !important; }
@@ -0,0 +1,15 @@
1
+ import { Toast } from "@heroui/react";
2
+ import { notificationQueue } from "../../lib/notifications";
3
+ import "./toast-region.css";
4
+
5
+ // Top-right, below the topbar, overlaying content so notices never shift layout.
6
+ export function ToastRegion() {
7
+ return <Toast.Provider queue={notificationQueue} placement="top end" className="app-toasts" width={380}>{({ toast }) =>
8
+ <Toast toast={toast} variant={toast.content.kind}>
9
+ <Toast.Indicator />
10
+ <Toast.Content><Toast.Title>{toast.content.title}</Toast.Title>{toast.content.description && <Toast.Description>{toast.content.description}</Toast.Description>}</Toast.Content>
11
+ {toast.content.action && <Toast.ActionButton onPress={() => { toast.content.action!.onPress(); notificationQueue.close(toast.key); }}>{toast.content.action.label}</Toast.ActionButton>}
12
+ <Toast.CloseButton aria-label="Dismiss notification" />
13
+ </Toast>}
14
+ </Toast.Provider>;
15
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * A file's facts on hover or focus, one per line: the card the context rail's
5
+ * files and the workspace tree share.
6
+ */
7
+ export function FileFacts({ name, sizeBytes, modifiedAt, children }: { name: string; sizeBytes?: number; modifiedAt?: Date | null; children?: ReactNode }) {
8
+ const extension = /\.([^.]+)$/.exec(name)?.[1];
9
+ return <>
10
+ <strong>{name}</strong>
11
+ {extension && <span>{extension.toUpperCase()}</span>}
12
+ {sizeBytes !== undefined && <span>{new Intl.NumberFormat("en").format(sizeBytes)} bytes</span>}
13
+ {modifiedAt && <span>{modifiedAt.toLocaleString("en", { dateStyle: "medium", timeStyle: "short" })}</span>}
14
+ {children}
15
+ </>;
16
+ }