@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,128 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { Button, Checkbox, Description, Label, ListBox, Popover, SearchField, Switch } from "@heroui/react";
3
+ import type { WorkStartRepository } from "@talgent/workbench-contracts/agent/public/v1/work_start_options_pb";
4
+ import { AlertTriangle, Bot, Cloud, GitBranch, Server, Shield } from "lucide-react";
5
+ import type { UsableComputer } from "../../lib/computer-service";
6
+ import { permissionModeOption } from "../../lib/permission-modes";
7
+ import type { WorkStartInput } from "../../lib/start-work-request";
8
+ import { providerName, useConnectProvider, useProviderConnection } from "../../lib/marketplace-connections";
9
+ import type { Capability } from "../../lib/work-start-loadout";
10
+ import { PermissionModeList } from "../composer/permission-mode-list";
11
+ import { PopoverFootnote } from "../composer/prompt-bar-controls";
12
+
13
+ /** A tray chip and its card. Every card shares one surface and row anatomy. */
14
+ export function TrayPopover({ label, value, icon, disabled, attention, open, onOpenChange, wide, children }: { label: string; value: string; icon: ReactNode; disabled: boolean; attention?: boolean; open: boolean; onOpenChange: (open: boolean) => void; wide?: boolean; children: ReactNode }) {
15
+ return <Popover isOpen={open} onOpenChange={onOpenChange}>
16
+ <Button size="sm" variant="ghost" className="wpb-config-trigger" isDisabled={disabled} aria-label={`${label}: ${value}`}>{icon}<span>{value}</span>{attention && <AlertTriangle size={13} className="text-warning" aria-label="Needs attention" />}</Button>
17
+ <Popover.Content placement="bottom start" className={`wpb-popover ${wide ? "wpb-popover-wide" : "wpb-popover-tray"}`}><Popover.Dialog aria-label={`${label} for this Work`}>{children}</Popover.Dialog></Popover.Content>
18
+ </Popover>;
19
+ }
20
+
21
+ export const ScopeNote = () => <PopoverFootnote>Applies to this Work only</PopoverFootnote>;
22
+
23
+ /** A check row: the control, the entry and its state, and an optional action. */
24
+ export function CheckRow({ label, checked, disabled, struck, title, secondary, warn, trailing, onChange }: { label: string; checked: boolean; disabled?: boolean; struck?: boolean; title: string; secondary?: string; warn?: boolean; trailing?: ReactNode; onChange: () => void }) {
25
+ return <div className="wst-check-row" data-struck={struck || undefined}>
26
+ <Checkbox aria-label={label} isSelected={checked} isDisabled={disabled} onChange={onChange} className="wst-check">
27
+ <Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
28
+ <span className="wst-check-copy"><span className="wst-check-title">{title}</span>{secondary && <span className={warn ? "text-warning" : "wpb-muted"}>{secondary}</span>}</span>
29
+ </Checkbox.Content>
30
+ </Checkbox>
31
+ {trailing}
32
+ </div>;
33
+ }
34
+
35
+ export function Search({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
36
+ return <SearchField className="wpb-search" aria-label={label} value={value} onChange={onChange} onKeyDown={event => { if (event.key === "Enter") event.preventDefault(); }}>
37
+ <SearchField.Group><SearchField.SearchIcon /><SearchField.Input autoFocus placeholder={label} /><SearchField.ClearButton aria-label="Clear search" /></SearchField.Group>
38
+ </SearchField>;
39
+ }
40
+
41
+ // Where the Work runs. With a single choice, the one Cloud, the picker only
42
+ // states it; otherwise it lists every Cloud, then the person's Managed
43
+ // Computers. An offline Managed Computer refuses a start, so it can't be picked.
44
+ // With no selection, a remembered choice that is gone, it lists them to choose
45
+ // again.
46
+ export function ComputerPicker({ computers, selected, disabled, open, onOpenChange, onChange }: { computers?: UsableComputer[]; selected?: UsableComputer; disabled: boolean; open: boolean; onOpenChange: (open: boolean) => void; onChange?: (id: string) => void }) {
47
+ if (!computers || !onChange || (computers.length < 2 && selected)) return <TrayPopover label="Computer" value="Cloud" icon={<Cloud size={16} />} disabled={disabled} open={open} onOpenChange={onOpenChange}>
48
+ <ListBox aria-label="Computer" selectionMode="single" disallowEmptySelection selectedKeys={["cloud"]}>
49
+ <ListBox.Item id="cloud" textValue="Cloud" className="wpb-choice"><Cloud size={16} /><span className="wpb-choice-copy"><Label>Cloud</Label><span className="wpb-muted">Managed by Talgent</span></span><ListBox.ItemIndicator /></ListBox.Item>
50
+ </ListBox>
51
+ </TrayPopover>;
52
+ const icon = (computer: UsableComputer) => computer.managed ? <Server size={16} /> : <Cloud size={16} />;
53
+ return <TrayPopover label="Computer" value={selected?.name ?? "Choose a Computer"} icon={selected ? icon(selected) : <Server size={16} />} attention={!selected?.reachable} disabled={disabled} open={open} onOpenChange={onOpenChange}>
54
+ <ListBox aria-label="Computer" selectionMode="single" disallowEmptySelection selectedKeys={selected ? [selected.id] : []} disabledKeys={computers.filter(computer => !computer.reachable).map(computer => computer.id)}
55
+ onSelectionChange={keys => { if (keys === "all" || !keys.size) return; onChange(String([...keys][0])); onOpenChange(false); }}>
56
+ {computers.map(computer => <ListBox.Item key={computer.id} id={computer.id} textValue={computer.reachable ? computer.name : `${computer.name}, offline`} className="wpb-choice">
57
+ {icon(computer)}<span className="wpb-choice-copy"><Label>{computer.name}</Label><span className="wpb-muted">{computer.managed ? "Managed Computer" : "Managed by Talgent"}</span></span>
58
+ {computer.reachable ? <ListBox.ItemIndicator /> : <span className="wst-offline">Offline</span>}
59
+ </ListBox.Item>)}
60
+ </ListBox>
61
+ </TrayPopover>;
62
+ }
63
+
64
+ // Repositories are what the assignee's GitHub authorization reaches now; the
65
+ // assignee's recently used ones come first. No binding step exists.
66
+ export function RepositoryPicker({ repositories, recent, unavailable, loading, selected, disabled, open, onOpenChange, onChange, onRetry, returnTo }: {
67
+ repositories: WorkStartRepository[]; recent: string[]; unavailable: boolean; loading: boolean; selected: string[]; disabled: boolean;
68
+ open: boolean; onOpenChange: (open: boolean) => void; onChange: (selected: string[]) => void; onRetry: () => void; returnTo: string;
69
+ }) {
70
+ const [query, setQuery] = useState("");
71
+ const github = useProviderConnection("github");
72
+ const connect = useConnectProvider();
73
+ const status = github.data?.state?.status;
74
+ const byId = new Map(repositories.map(repository => [repository.id, repository]));
75
+ const value = selected.length === 0 ? "Repository" : selected.length === 1 ? byId.get(selected[0])?.repoName ?? "1 repository" : `${selected.length} repositories`;
76
+ const matches = repositories.filter(repository => repository.repoName.toLowerCase().includes(query.trim().toLowerCase()));
77
+ const recentRows = recent.map(id => byId.get(id)).filter((item): item is WorkStartRepository => Boolean(item));
78
+ const toggle = (id: string) => onChange(selected.includes(id) ? selected.filter(item => item !== id) : [...selected, id]);
79
+ const row = (repository: WorkStartRepository) => <CheckRow key={repository.id} label={repository.repoName} checked={selected.includes(repository.id)} title={repository.repoName} secondary={repository.defaultBranch} onChange={() => toggle(repository.id)} />;
80
+ const group = (title: string, items: WorkStartRepository[]) => items.length > 0 && <div className="wst-rows" role="group" aria-label={title}><p className="wst-group-title">{title}</p>{items.map(row)}</div>;
81
+ const needsConnection = status !== undefined && status !== "active";
82
+ return <TrayPopover label="Repositories" value={value} icon={<GitBranch size={16} />} disabled={disabled} open={open} onOpenChange={value => { onOpenChange(value); if (value) setQuery(""); }}>
83
+ {loading ? <p className="wpb-empty" role="status">Loading repositories…</p>
84
+ : needsConnection && !repositories.length ? <div className="wst-picker-empty">
85
+ <p>{status === "reconnect_required" ? "Your GitHub authorization expired. Reconnect to check out repositories." : "Connect GitHub to check out repositories in this Work. You can start without one."}</p>
86
+ <Button size="sm" variant="secondary" isPending={connect.isPending} onPress={() => connect.mutate({ provider: "github", returnTo })}>{status === "reconnect_required" ? "Reconnect GitHub" : `Connect ${providerName("github")}`}</Button>
87
+ </div>
88
+ : unavailable ? <p className="wpb-empty" role="alert">Repositories couldn’t be loaded. You can start without one. <Button size="sm" variant="ghost" onPress={onRetry}>Retry</Button></p>
89
+ : <>
90
+ <Search label="Search repositories" value={query} onChange={setQuery} />
91
+ <div className="wst-scroll">
92
+ {!repositories.length ? <p className="wpb-empty" role="status">GitHub doesn’t allow Talgent into any repository yet.</p>
93
+ : query ? matches.length ? <div className="wst-rows">{matches.map(row)}</div> : <p className="wpb-empty" role="status">No repositories match.</p>
94
+ : <>{group("Recent", recentRows)}{group(recentRows.length ? "All repositories" : "Repositories", repositories.filter(repository => !recent.includes(repository.id)))}</>}
95
+ </div>
96
+ <ScopeNote />
97
+ </>}
98
+ </TrayPopover>;
99
+ }
100
+
101
+ // One Startup Agent from the Plugins included in this Work.
102
+ export function AgentPicker({ agents, value, disabled, open, onOpenChange, onChange }: { agents: Capability[]; value: string; disabled: boolean; open: boolean; onOpenChange: (open: boolean) => void; onChange: (agent: string) => void }) {
103
+ return <TrayPopover label="Startup Agent" value={value ? value.split(":").at(-1)! : "Default Agent"} icon={<Bot size={16} />} disabled={disabled} open={open} onOpenChange={onOpenChange}>
104
+ <ListBox aria-label="Startup Agent" selectionMode="single" selectedKeys={[value || "default"]} onSelectionChange={keys => { if (keys === "all" || !keys.size) return; const next = String([...keys][0]); onChange(next === "default" ? "" : next); onOpenChange(false); }}>
105
+ <ListBox.Item id="default" textValue="Default Agent" className="wpb-choice"><Bot size={16} /><span className="wpb-choice-copy"><Label>Default Agent</Label><span className="wpb-muted">Talgent</span></span><ListBox.ItemIndicator /></ListBox.Item>
106
+ {agents.map(agent => <ListBox.Item key={agent.name} id={agent.name} textValue={agent.name} className="wpb-choice"><Bot size={16} /><span className="wpb-choice-copy"><Label title={agent.description}>{agent.name.split(":").at(-1)}</Label><span className="wpb-muted">{agent.plugin}</span></span><ListBox.ItemIndicator /></ListBox.Item>)}
107
+ </ListBox>
108
+ </TrayPopover>;
109
+ }
110
+
111
+ type Harness = NonNullable<WorkStartInput["harness"]>;
112
+
113
+ // How the Harness runs on a Managed Computer: its permission mode, and whether
114
+ // it runs in Claude Code's sandbox. A Cloud has neither, so it is never offered.
115
+ // A machine that reports no sandbox can't be asked for one.
116
+ export function HarnessPicker({ value, sandboxAvailable, disabled, open, onOpenChange, onChange }: { value: Harness; sandboxAvailable: boolean; disabled: boolean; open: boolean; onOpenChange: (open: boolean) => void; onChange: (value: Harness) => void }) {
117
+ const mode = permissionModeOption(value.permissionMode);
118
+ return <TrayPopover label="Permissions" value={`${mode?.short ?? "Permissions"}${value.sandbox ? " · Sandbox" : ""}`} icon={<Shield size={16} />} disabled={disabled} open={open} onOpenChange={onOpenChange}>
119
+ <PermissionModeList value={value.permissionMode} onChange={permissionMode => onChange({ ...value, permissionMode })} />
120
+ <div className="wst-switch-row">
121
+ <Switch size="sm" isSelected={value.sandbox} isDisabled={!sandboxAvailable} onChange={sandbox => onChange({ ...value, sandbox })}>
122
+ <Switch.Content>Run in Claude Code’s sandbox<Switch.Control><Switch.Thumb /></Switch.Control></Switch.Content>
123
+ {!sandboxAvailable && <Description className="wst-switch-note">This Computer can’t provide it</Description>}
124
+ </Switch>
125
+ </div>
126
+ <ScopeNote />
127
+ </TrayPopover>;
128
+ }
@@ -0,0 +1,11 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /** The value once it has stayed unchanged for `delayMs`, such as typed search text. */
4
+ export function useDebounced<T>(value: T, delayMs: number): T {
5
+ const [settled, setSettled] = useState(value);
6
+ useEffect(() => {
7
+ const timer = setTimeout(() => setSettled(value), delayMs);
8
+ return () => clearTimeout(timer);
9
+ }, [value, delayMs]);
10
+ return settled;
11
+ }
@@ -0,0 +1,125 @@
1
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
2
+ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
3
+ import type { MessageHistoryCursor, WorkStateFrame } from "@talgent/workbench-contracts/streamgateway/public/v1/streamgateway_pb";
4
+ import { ContentBlockType, UnifiedMessageKind, type UnifiedMessage } from "@talgent/workbench-contracts/dispatcher/public/v1/unified_message_pb";
5
+ import { useWorkStreamSource } from "../lib/work-stream-source";
6
+ import { WorkConversation } from "../lib/work-conversation";
7
+ import { compareMessageCursor, useWorkStream } from "./use-work-stream";
8
+ import { notifyWorkNotice } from "../lib/work-notices";
9
+
10
+ interface TurnAnchor { id: string; turnId: string; title: string; timestamp: NonNullable<UnifiedMessage["timestamp"]> }
11
+ function turnAnchor(message: UnifiedMessage): TurnAnchor[] {
12
+ if (message.kind !== UnifiedMessageKind.USER || message.agent !== "" || !message.turnId || !message.timestamp || message.payload.case !== "message") return [];
13
+ const title = message.payload.value.content.filter(block => block.type === ContentBlockType.TEXT).map(block => block.text.slice(0, 160)).join("\n\n").slice(0, 160) || "Image attachment";
14
+ return [{ id: message.id, turnId: message.turnId, timestamp: message.timestamp, title }];
15
+ }
16
+ const cursorFor = (anchor: { id: string; timestamp: TurnAnchor["timestamp"] }): MessageHistoryCursor => ({ $typeName: "talgent.streamgateway.public.v1.MessageHistoryCursor", id: anchor.id, timestamp: anchor.timestamp });
17
+
18
+ export function useWorkConversation(workId: string | undefined, onWorkState?: (state: WorkStateFrame) => void, onReconnect?: () => void, onActivity?: () => void, onAccessWithdrawn?: () => void) {
19
+ const streamClient = useWorkStreamSource();
20
+ const [navigation, setNavigation] = useState<{ workId?: string; generation: number; before?: MessageHistoryCursor; from?: MessageHistoryCursor }>({ workId, generation: 0 });
21
+ const generation = navigation.workId === workId ? navigation.generation : 0;
22
+ const before = navigation.workId === workId ? navigation.before : undefined;
23
+ const from = navigation.workId === workId ? navigation.from : undefined;
24
+ const browsingHistory = Boolean(before || from);
25
+ const [liveAnchors, setLiveAnchors] = useState<{ workId?: string; users: TurnAnchor[] }>({ workId, users: [] });
26
+ // Index only user messages, independently of the bounded transcript window.
27
+ // Retain titles/cursors, never image bodies or the original query response.
28
+ const index = useInfiniteQuery({
29
+ queryKey: ["work-turn-index", workId], enabled: Boolean(workId),
30
+ initialPageParam: undefined as MessageHistoryCursor | undefined,
31
+ queryFn: async ({ pageParam, signal }) => {
32
+ const page = await streamClient.listMessagesBySession({ agentWorkId: workId!, agent: "", kinds: [UnifiedMessageKind.USER], limit: 100, before: pageParam }, { signal });
33
+ return { users: page.messages.flatMap(turnAnchor), next: page.hasMore ? page.nextBefore : undefined };
34
+ },
35
+ getNextPageParam: page => page.next,
36
+ maxPages: 5, retry: false, gcTime: 0, staleTime: Infinity, refetchOnWindowFocus: false, refetchOnReconnect: false,
37
+ });
38
+ const liveTail = useMemo(() => new WorkConversation(workId ?? "", 100, 2 * 1024 * 1024), [workId]);
39
+ // Returning to latest deliberately replaces the retained history window.
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ const conversation = useMemo(() => new WorkConversation(workId ?? ""), [workId, generation]);
42
+ // Replay bursts update the bounded window immediately, but notify React at
43
+ // most once per frame instead of rendering the transcript for each item.
44
+ const subscribe = useCallback((listener: () => void) => {
45
+ let timer: ReturnType<typeof setTimeout> | undefined;
46
+ const unsubscribe = conversation.subscribe(() => {
47
+ timer ??= setTimeout(() => { timer = undefined; listener(); }, 16);
48
+ });
49
+ return () => { clearTimeout(timer); unsubscribe(); };
50
+ }, [conversation]);
51
+ const snapshot = useSyncExternalStore(subscribe, conversation.getSnapshot);
52
+ const [arrival, setArrival] = useState<WorkConversation | null>(null);
53
+ const history = useQuery({
54
+ queryKey: ["work-conversation", workId, generation, before?.timestamp?.seconds.toString(), before?.timestamp?.nanos, before?.id, from?.timestamp?.seconds.toString(), from?.timestamp?.nanos, from?.id],
55
+ enabled: Boolean(workId),
56
+ queryFn: async ({ signal }) => {
57
+ const page = await streamClient.listMessagesBySession({ agentWorkId: workId!, agent: "", includeUnattributedErrors: true, limit: 100, before, from }, { signal });
58
+ // Apply the same byte ceiling before retaining the query response too.
59
+ const window = new WorkConversation(workId!, 100);
60
+ window.add(page.messages, from ? "older" : "history");
61
+ const retained = window.getSnapshot();
62
+ return { ...page, messages: retained.turns.flatMap(turn => turn.messages), hasMore: page.hasMore || retained.olderEvicted };
63
+ },
64
+ retry: false, gcTime: 0, staleTime: Infinity, refetchOnWindowFocus: false, refetchOnReconnect: false,
65
+ });
66
+ useEffect(() => {
67
+ // Live delivery can precede visibility in stored history. Reuse the bounded
68
+ // tail on a window reset rather than losing those messages during that gap.
69
+ if (from) return;
70
+ conversation.add(liveTail.getSnapshot().turns.flatMap(turn => turn.messages), "replay");
71
+ // A forward cursor changes within the same window; seed only on reset.
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, [conversation, liveTail]);
74
+ useEffect(() => {
75
+ if (history.data) conversation.add(history.data.messages, before ? "older" : "history");
76
+ }, [conversation, history.data, before]);
77
+ const navigationTurns = useMemo(() => {
78
+ const users = [...(index.data?.pages.flatMap(page => page.users) ?? []), ...(liveAnchors.workId === workId ? liveAnchors.users : []), ...snapshot.turns.flatMap(turn => turn.messages.flatMap(turnAnchor))];
79
+ users.sort((a, b) => compareMessageCursor(a.timestamp, a.id, b.timestamp, b.id));
80
+ const turns = new Map<string, TurnAnchor>();
81
+ for (const user of users) if (!turns.has(user.turnId)) turns.set(user.turnId, user);
82
+ return [...turns.values()];
83
+ }, [index.data, liveAnchors, workId, snapshot.turns]);
84
+ useWorkStream({
85
+ workId, includeUnattributedErrors: true, onWorkState, onAccessWithdrawn,
86
+ onDiagnostic: diagnostic => { if (workId) notifyWorkNotice(workId, diagnostic); },
87
+ onMessage: (message, replay) => {
88
+ if (!replay && [UnifiedMessageKind.USER, UnifiedMessageKind.RESULT, UnifiedMessageKind.PERMISSION, UnifiedMessageKind.INTERACTION, UnifiedMessageKind.ERROR].includes(message.kind)) onActivity?.();
89
+ const anchors = turnAnchor(message);
90
+ if (anchors.length) setLiveAnchors(current => ({ workId, users: [...(current.workId === workId ? current.users.filter(user => user.id !== message.id) : []), ...anchors].slice(-100) }));
91
+ liveTail.add([message], replay ? "replay" : "live");
92
+ // While reading older pages, keep the reading window intact. Returning to
93
+ // latest combines authoritative history with the bounded recent tail.
94
+ if (browsingHistory && !conversation.has(message.id)) {
95
+ if (!replay && message.kind === UnifiedMessageKind.USER) setNavigation({ workId, generation });
96
+ else { setArrival(conversation); return; }
97
+ }
98
+ conversation.add([message], replay ? "replay" : "live");
99
+ },
100
+ onReconnect: () => { if (!browsingHistory) void history.refetch(); void index.refetch(); onReconnect?.(); },
101
+ });
102
+ const hasMore = Boolean(from || history.data?.hasMore || snapshot.olderEvicted);
103
+ return {
104
+ ...snapshot,
105
+ loading: history.isFetching, error: history.isError, hasMore,
106
+ navigationTurns, indexLoading: index.isFetching, indexError: index.isError, hasOlderTurns: Boolean(index.hasNextPage),
107
+ loadOlderTurns: () => { if (!index.isFetching) void index.fetchNextPage(); },
108
+ retryIndex: () => { void index.refetch(); },
109
+ browsingHistory, hasNewer: arrival === conversation,
110
+ retry: () => { void history.refetch(); },
111
+ loadOlder: () => {
112
+ const oldest = conversation.getOldest();
113
+ if (!workId || history.isFetching || !hasMore || !oldest?.timestamp) return;
114
+ setNavigation({ workId, generation, before: { $typeName: "talgent.streamgateway.public.v1.MessageHistoryCursor", timestamp: oldest.timestamp, id: oldest.id } });
115
+ },
116
+ hasLaterMessages: browsingHistory && (Boolean(before) || Boolean(history.data?.hasMore)),
117
+ loadNewer: () => {
118
+ const newest = conversation.getNewest();
119
+ if (!workId || history.isFetching || !newest?.timestamp) return;
120
+ setNavigation({ workId, generation, from: cursorFor({ id: newest.id, timestamp: newest.timestamp }) });
121
+ },
122
+ showTurn: (anchor: TurnAnchor) => setNavigation({ workId, generation: generation + 1, from: cursorFor(anchor) }),
123
+ showLatest: () => setNavigation({ workId, generation: generation + 1 }),
124
+ };
125
+ }
@@ -0,0 +1,40 @@
1
+ import { useRef, useState } from "react";
2
+ import type { WorkInfo } from "@talgent/workbench-contracts/agent/public/v1/work_pb";
3
+ import { agentWorkClient } from "../lib/client";
4
+ import { isStaleWorkControl, workControlErrorMessage } from "../lib/work-projection";
5
+
6
+ // Every control names the Work version the user saw, except resuming with
7
+ // the owner's own message. onSettled refreshes the authority, so a rejected
8
+ // stale control is followed by the current state and the user confirms again
9
+ // against it. Any other refusal describes the version it was raised against,
10
+ // so it is withdrawn once the Work moves on; a refused resume after a message
11
+ // stays until the next action, since that message already moved the Work on.
12
+ export function useWorkLifecycleControls(workId: string | null | undefined, onWorkChange: (work?: WorkInfo) => void, onSettled: () => void, expectedVersion?: bigint) {
13
+ const inFlight = useRef(false);
14
+ const [pending, setPending] = useState(false);
15
+ const [failure, setFailure] = useState<{ message: string; version?: bigint }>();
16
+ const [archiveOpen, setArchiveOpen] = useState(false);
17
+ const run = async (action: () => Promise<void>, versioned = true) => {
18
+ if (!workId || inFlight.current) return;
19
+ inFlight.current = true; setPending(true); setFailure(undefined);
20
+ try { await action(); }
21
+ catch (cause) {
22
+ const stale = isStaleWorkControl(cause);
23
+ setFailure({ message: workControlErrorMessage(cause), version: stale || !versioned ? undefined : expectedVersion });
24
+ // The confirmation was given for a state that no longer holds.
25
+ if (stale) setArchiveOpen(false);
26
+ }
27
+ finally { inFlight.current = false; setPending(false); onSettled(); }
28
+ };
29
+ const error = failure && (failure.version === undefined || failure.version === expectedVersion) ? failure.message : "";
30
+ const request = () => ({ workId: workId!, expectedVersion });
31
+ const stop = () => run(async () => onWorkChange((await agentWorkClient.stopWork(request())).work));
32
+ const resume = () => run(async () => onWorkChange((await agentWorkClient.resumeWork(request())).work));
33
+ // Resuming after accepted input is the caller's decision. The
34
+ // owner's message itself moved the Work on, so the resume that continues it
35
+ // names no version; the Work's state still decides.
36
+ const continueWithInput = () => run(async () => onWorkChange((await agentWorkClient.resumeWork({ workId: workId! })).work), false);
37
+ const restart = () => run(async () => onWorkChange((await agentWorkClient.restartWork(request())).work));
38
+ const archive = () => run(async () => { onWorkChange((await agentWorkClient.archiveWork(request())).work); setArchiveOpen(false); });
39
+ return { pending, error, archiveOpen, setArchiveOpen, run, stop, resume, continueWithInput, restart, archive };
40
+ }
@@ -0,0 +1,91 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { Timestamp } from "@bufbuild/protobuf/wkt";
3
+ import { UnifiedMessageKind, type UnifiedMessage } from "@talgent/workbench-contracts/dispatcher/public/v1/unified_message_pb";
4
+ import type { StreamDiagnosticFrame, WorkStateFrame, WatchStreamResponse } from "@talgent/workbench-contracts/streamgateway/public/v1/streamgateway_pb";
5
+ import { useWorkStreamSource } from "../lib/work-stream-source";
6
+
7
+ interface WorkStreamOptions {
8
+ workId?: string;
9
+ kinds?: UnifiedMessageKind[];
10
+ includeUnattributedErrors?: boolean;
11
+ onMessage: (message: UnifiedMessage, replay: boolean) => void;
12
+ onWorkState?: (state: WorkStateFrame) => void;
13
+ /** A live notice that is not part of the message stream and is never replayed. */
14
+ onDiagnostic?: (diagnostic: StreamDiagnosticFrame) => void;
15
+ onReconnect?: () => void;
16
+ /** The reader's access ended; the stream stops and does not reconnect. */
17
+ onAccessWithdrawn?: () => void;
18
+ }
19
+
20
+ // Use the slim product contract directly. Connection diagnostics are connection
21
+ // state, never fabricated Harness errors or transcript messages.
22
+ export function useWorkStream(options: WorkStreamOptions) {
23
+ const callbacks = useRef(options);
24
+ useEffect(() => { callbacks.current = options; });
25
+ const { workId, kinds, includeUnattributedErrors = false } = options;
26
+ const source = useWorkStreamSource();
27
+ const kindsKey = kinds?.join(",") ?? "";
28
+
29
+ useEffect(() => {
30
+ if (!workId) return;
31
+ const abort = new AbortController();
32
+ const filters = kindsKey ? kindsKey.split(",").map(Number) as UnifiedMessageKind[] : [];
33
+ let cursor: { timestamp?: Timestamp; id: string } = { id: "" };
34
+ let attempt = 0;
35
+ let reconnecting = false;
36
+ void (async () => {
37
+ while (!abort.signal.aborted) {
38
+ if (reconnecting) callbacks.current.onReconnect?.();
39
+ try {
40
+ const events: AsyncIterable<WatchStreamResponse> = source.watchStream({
41
+ agentWorkId: workId, agent: "", kinds: filters, includeUnattributedErrors,
42
+ afterTimestamp: cursor.timestamp, afterId: cursor.id,
43
+ }, { signal: abort.signal });
44
+ for await (const event of events) {
45
+ if (abort.signal.aborted) break;
46
+ attempt = 0;
47
+ if (event.payload.case === "message") {
48
+ const message: UnifiedMessage = event.payload.value;
49
+ const unknownError = includeUnattributedErrors && message.agent === undefined && message.kind === UnifiedMessageKind.ERROR;
50
+ if (message.agentWorkId !== workId || (message.agent !== "" && !unknownError) || (filters.length && !filters.includes(message.kind))) continue;
51
+ callbacks.current.onMessage(message, event.replay);
52
+ if (message.timestamp && (!cursor.timestamp || compareMessageCursor(message.timestamp, message.id, cursor.timestamp, cursor.id) > 0)) {
53
+ cursor = { timestamp: message.timestamp, id: message.id };
54
+ }
55
+ } else if (event.payload.case === "workState" && event.payload.value.agentWorkId === workId) {
56
+ callbacks.current.onWorkState?.(event.payload.value);
57
+ } else if (event.payload.case === "diagnostic" && event.payload.value.agentWorkId === workId) {
58
+ callbacks.current.onDiagnostic?.(event.payload.value);
59
+ } else if (event.payload.case === "accessWithdrawn" && event.payload.value.agentWorkId === workId) {
60
+ // Withdrawal is not a connection error: never reconnect.
61
+ callbacks.current.onAccessWithdrawn?.();
62
+ return;
63
+ }
64
+ }
65
+ } catch {
66
+ // Reconnect below from the last received cursor without a UI warning.
67
+ }
68
+ if (abort.signal.aborted) break;
69
+ reconnecting = true;
70
+ await reconnectDelay(Math.min(5000, 250 * 2 ** attempt++), abort.signal);
71
+ }
72
+ })();
73
+ return () => abort.abort();
74
+ }, [workId, kindsKey, includeUnattributedErrors, source]);
75
+
76
+ }
77
+
78
+ export function compareMessageCursor(a: Timestamp, aID: string, b: Timestamp, bID: string) {
79
+ if (a.seconds !== b.seconds) return a.seconds < b.seconds ? -1 : 1;
80
+ if (a.nanos !== b.nanos) return a.nanos < b.nanos ? -1 : 1;
81
+ return aID < bID ? -1 : aID > bID ? 1 : 0;
82
+ }
83
+
84
+ function reconnectDelay(ms: number, signal: AbortSignal) {
85
+ return new Promise<void>(resolve => {
86
+ const done = () => { window.clearTimeout(timer); signal.removeEventListener("abort", done); resolve(); };
87
+ const timer = window.setTimeout(done, ms);
88
+ signal.addEventListener("abort", done, { once: true });
89
+ if (signal.aborted) done();
90
+ });
91
+ }
@@ -0,0 +1,77 @@
1
+ import { Code, ConnectError } from "@connectrpc/connect";
2
+ import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
3
+ import { useNavigate } from "react-router-dom";
4
+ import { sessionClient } from "./client";
5
+ import { dismiss, notify } from "./notifications";
6
+ import { signOut } from "./session";
7
+ import { safeAvatarUrl, safeDisplayName, safeEmail } from "./user-display";
8
+
9
+ export const currentUserQueryKey = ["account", "current-user"] as const;
10
+
11
+ export type CurrentUser = {
12
+ id: string;
13
+ userId: string;
14
+ username: string;
15
+ displayName: string;
16
+ primaryEmail: string;
17
+ avatarUrl: string;
18
+ firstName?: string;
19
+ lastName?: string;
20
+ nickName?: string;
21
+ preferredLanguage?: string;
22
+ gender?: string;
23
+ };
24
+
25
+ type CurrentUserView = {
26
+ user?: CurrentUser;
27
+ };
28
+
29
+ // Account ids are canonical lowercase UUIDs, the only subject the gateway admits.
30
+ function isAccountId(value: string | undefined): value is string {
31
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value?.trim() ?? "");
32
+ }
33
+
34
+ export function useCurrentUser() {
35
+ return useQuery<CurrentUserView>({
36
+ queryKey: currentUserQueryKey,
37
+ queryFn: async () => {
38
+ const response = await sessionClient.getCurrentSession({});
39
+ const session = response.session;
40
+ if (!isAccountId(session?.userId)) {
41
+ throw new ConnectError("unauthenticated", Code.Unauthenticated);
42
+ }
43
+ const username = safeDisplayName(session.username, session.userId, "");
44
+ const primaryEmail = safeEmail(session.primaryEmail);
45
+ const displayName = safeDisplayName(session.displayName, session.userId, session.primaryEmail || "Profile");
46
+ return {
47
+ user: {
48
+ id: session.userId,
49
+ userId: session.userId,
50
+ username,
51
+ displayName,
52
+ primaryEmail,
53
+ avatarUrl: safeAvatarUrl(session.avatarUrl),
54
+ },
55
+ };
56
+ },
57
+ retry: false,
58
+ refetchOnWindowFocus: "always",
59
+ staleTime: 60_000,
60
+ });
61
+ }
62
+
63
+ // Ends this device's Login Session, forgets everything loaded for it and
64
+ // returns to the sign-in page.
65
+ export function useLogout() {
66
+ const queryClient = useQueryClient();
67
+ const navigate = useNavigate();
68
+ return useMutation({
69
+ mutationFn: signOut,
70
+ onError: () => notify("sign-out", "danger", "You’re still signed in", "Signing out didn’t reach Talgent. Check your connection and try again."),
71
+ onSuccess: () => {
72
+ dismiss("sign-out");
73
+ navigate("/login", { replace: true });
74
+ queryClient.clear();
75
+ },
76
+ });
77
+ }
@@ -0,0 +1,59 @@
1
+ import { ConnectError, Code } from "@connectrpc/connect";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useCurrentUser } from "./account-service";
4
+ import { agentWorkClient } from "./client";
5
+ import { notify } from "./notifications";
6
+ import { agentIconOf, type AgentIconValue } from "./agent-icon";
7
+
8
+ // The signed-in person's Work Agent: its name and icon are theirs to set.
9
+ // Before it exists, agentId is empty and the appearance is the one it would
10
+ // start with.
11
+ export interface MyWorkAgent { agentId: string; name: string; icon: AgentIconValue }
12
+
13
+ /** The meta of a query whose data shows the person's Work Agent, such as a
14
+ * thread naming its replies' author: saving the Agent's appearance refreshes it. */
15
+ export const showsWorkAgent = { showsWorkAgent: true };
16
+
17
+ const myWorkAgentKey = (userId: string | undefined) => ["my-work-agent", userId];
18
+
19
+ export function useMyWorkAgent() {
20
+ const userId = useCurrentUser().data?.user?.id;
21
+ return useQuery({
22
+ queryKey: myWorkAgentKey(userId),
23
+ queryFn: async (): Promise<MyWorkAgent> => {
24
+ const agent = await agentWorkClient.getMyWorkAgent({});
25
+ return { agentId: agent.agentId, name: agent.appearance?.name ?? "", icon: agentIconOf(agent.appearance?.icon) };
26
+ },
27
+ enabled: Boolean(userId),
28
+ retry: false,
29
+ refetchOnWindowFocus: false,
30
+ });
31
+ }
32
+
33
+ export function useChangeMyWorkAgentAppearance() {
34
+ const queryClient = useQueryClient();
35
+ const userId = useCurrentUser().data?.user?.id;
36
+ return useMutation({
37
+ mutationFn: async (input: MyWorkAgent): Promise<MyWorkAgent> => {
38
+ // Saving is the first use: Agent establishes the Work Agent if it has none yet.
39
+ const agentId = input.agentId || (await agentWorkClient.ensureMyWorkAgent({})).agentId;
40
+ const response = await agentWorkClient.changeWorkAgentAppearance({ agentId, name: input.name, icon: input.icon });
41
+ return { agentId, name: response.appearance?.name ?? input.name, icon: agentIconOf(response.appearance?.icon ?? input.icon) };
42
+ },
43
+ onSuccess: agent => {
44
+ // Saving remounts the editor with the saved appearance, so the notice
45
+ // belongs to the mutation rather than the form.
46
+ notify("work-agent-appearance", "success", "Work Agent saved");
47
+ queryClient.setQueryData(myWorkAgentKey(userId), agent);
48
+ // Every place the Agent appears reads its current appearance.
49
+ void queryClient.invalidateQueries({ predicate: query => query.meta?.showsWorkAgent === true });
50
+ },
51
+ });
52
+ }
53
+
54
+ export function agentAppearanceErrorMessage(error: unknown) {
55
+ const code = error instanceof ConnectError ? error.code : undefined;
56
+ if (code === Code.InvalidArgument) return "Use a name of 1–40 characters and one of the listed icons and colors.";
57
+ if (code === Code.PermissionDenied) return "Only the person a Work Agent represents can change it.";
58
+ return "Your Work Agent could not be saved. Try again.";
59
+ }
@@ -0,0 +1,26 @@
1
+ import { Atom, Bird, Bot, Brain, Cat, Compass, Flame, Leaf, Rocket, Sparkles, Star, Wand2, type LucideIcon } from "lucide-react";
2
+
3
+ // A Work Agent's icon: one preset glyph on one preset color. Agent owns the
4
+ // preset ids; anything it does not know reads as the default green robot.
5
+ export const agentGlyphs: ReadonlyArray<{ id: string; label: string; Icon: LucideIcon }> = [
6
+ { id: "bot", label: "Bot", Icon: Bot }, { id: "sparkles", label: "Sparkles", Icon: Sparkles },
7
+ { id: "brain", label: "Brain", Icon: Brain }, { id: "rocket", label: "Rocket", Icon: Rocket },
8
+ { id: "wand", label: "Wand", Icon: Wand2 }, { id: "cat", label: "Cat", Icon: Cat },
9
+ { id: "bird", label: "Bird", Icon: Bird }, { id: "leaf", label: "Leaf", Icon: Leaf },
10
+ { id: "flame", label: "Flame", Icon: Flame }, { id: "star", label: "Star", Icon: Star },
11
+ { id: "compass", label: "Compass", Icon: Compass }, { id: "atom", label: "Atom", Icon: Atom },
12
+ ];
13
+
14
+ // The label palette, as explicit inks: Tailwind v4 emits only the
15
+ // color variables a stylesheet uses.
16
+ export const agentColors = { slate: "#475569", blue: "#2563eb", violet: "#7c3aed", green: "#16a34a", amber: "#d97706", rose: "#e11d48" } as const;
17
+ export type AgentColor = keyof typeof agentColors;
18
+
19
+ export interface AgentIconValue { glyph: string; color: string }
20
+ export const defaultAgentIcon: AgentIconValue = { glyph: "bot", color: "green" };
21
+
22
+ export function agentIconOf(icon: Partial<AgentIconValue> | undefined): { glyph: string; color: AgentColor } {
23
+ const glyph = agentGlyphs.some(item => item.id === icon?.glyph) ? icon!.glyph! : defaultAgentIcon.glyph;
24
+ const color = (icon?.color && icon.color in agentColors ? icon.color : defaultAgentIcon.color) as AgentColor;
25
+ return { glyph, color };
26
+ }
@@ -0,0 +1,48 @@
1
+ import { ConnectError, Code } from "@connectrpc/connect";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useCurrentUser } from "./account-service";
4
+ import { agentWorkClient } from "./client";
5
+ import { notify } from "./notifications";
6
+
7
+ // The Soul of the signed-in person's Work Agent: the persona and working style
8
+ // every one of its Works takes up at its next start. Only its Owner reads it.
9
+ export const MAX_SOUL = 8000;
10
+
11
+ const soulKey = (userId: string | undefined, agentId: string) => ["agent-soul", userId, agentId];
12
+
13
+ /** Reads the Soul; a Work Agent that does not exist yet has none. */
14
+ export function useWorkAgentSoul(agentId: string | undefined) {
15
+ const userId = useCurrentUser().data?.user?.id;
16
+ return useQuery({
17
+ queryKey: soulKey(userId, agentId ?? ""),
18
+ queryFn: async () => agentId ? (await agentWorkClient.getAgentSoul({ agentId })).soul : "",
19
+ enabled: Boolean(userId) && agentId !== undefined,
20
+ retry: false,
21
+ refetchOnWindowFocus: false,
22
+ });
23
+ }
24
+
25
+ export function useChangeWorkAgentSoul() {
26
+ const queryClient = useQueryClient();
27
+ const userId = useCurrentUser().data?.user?.id;
28
+ return useMutation({
29
+ mutationFn: async ({ agentId, soul }: { agentId: string; soul: string }) => {
30
+ // Saving is the first use: Agent establishes the Work Agent if it has none yet.
31
+ const id = agentId || (await agentWorkClient.ensureMyWorkAgent({})).agentId;
32
+ const response = await agentWorkClient.changeAgentSoul({ agentId: id, soul });
33
+ return { agentId: id, soul: response.soul };
34
+ },
35
+ onSuccess: ({ agentId, soul }, input) => {
36
+ notify("work-agent-soul", "success", "Soul saved", "Your Works take it up at their next start.");
37
+ queryClient.setQueryData(soulKey(userId, agentId), soul);
38
+ if (!input.agentId) void queryClient.invalidateQueries({ queryKey: ["my-work-agent", userId] });
39
+ },
40
+ });
41
+ }
42
+
43
+ export function soulErrorMessage(error: unknown) {
44
+ const code = error instanceof ConnectError ? error.code : undefined;
45
+ if (code === Code.InvalidArgument) return `Use at most ${MAX_SOUL} characters.`;
46
+ if (code === Code.PermissionDenied) return "Only your Work Agent's Owner can change its Soul.";
47
+ return "The Soul could not be saved. Try again.";
48
+ }
@@ -0,0 +1,23 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { agentWorkClient } from "./client";
3
+
4
+ // A Work as Agent shows it to its owner (AgentWorkService.GetWork). Every
5
+ // reader of one Work shares this cache entry, which holds Agent's response.
6
+ export const agentWorkQueryKey = (workId: string | null | undefined) => ["agent-work", workId] as const;
7
+
8
+ /** Polls a Work's state while it starts or runs; a closed Work no longer changes. */
9
+ export function workPollInterval(data: { work?: { state: string } } | undefined) {
10
+ return data?.work?.state === "closed" ? false : 5000;
11
+ }
12
+
13
+ /** The owner's view of the Work; Agent refuses anyone else. */
14
+ export function useAgentWork(workId: string | null | undefined, enabled = true) {
15
+ return useQuery({
16
+ queryKey: agentWorkQueryKey(workId),
17
+ queryFn: ({ signal }) => agentWorkClient.getWork({ workId: workId! }, { signal }),
18
+ enabled: Boolean(workId) && enabled,
19
+ refetchInterval: query => workPollInterval(query.state.data),
20
+ retry: false,
21
+ refetchOnWindowFocus: false,
22
+ });
23
+ }