@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,175 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
4
+ import { traceClient } from "./client";
5
+ import { buildTraceMetricsDiagnostics, traceMetricKinds } from "./trace-view-model";
6
+
7
+ const terminalTraceGracePollCount = 3;
8
+ const traceRefreshIntervalMs = 10000;
9
+ const terminalTraceGracePollIntervalMs = traceRefreshIntervalMs;
10
+ export function useWorkTrace({ projectId, agentWorkId, workCreatedAt = null, isWorkTerminal = false, isWorkLifecyclePending = false, workLifecycleGeneration = 0 }: {
11
+ projectId?: string | null; agentWorkId?: string | null; workCreatedAt?: Date | null;
12
+ isWorkTerminal?: boolean; isWorkLifecyclePending?: boolean; workLifecycleGeneration?: number;
13
+ }) {
14
+ // A Work without a Project is read by its own identity.
15
+ const enabled = Boolean(agentWorkId);
16
+ const terminalKey = useRef<string | null>(null);
17
+ const currentKey = `${agentWorkId}:${workLifecycleGeneration}`;
18
+ const activeTraceSeenRef = useRef(false);
19
+ const terminalGraceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
20
+ const terminalGracePollsRemainingRef = useRef(0);
21
+ const shouldQueryTrace = enabled && !isWorkLifecyclePending && !isWorkTerminal;
22
+ const {
23
+ data,
24
+ error,
25
+ isError,
26
+ isLoading,
27
+ isFetching,
28
+ dataUpdatedAt,
29
+ refetch,
30
+ } = useQuery({
31
+ queryKey: ["trace-metrics", projectId, agentWorkId, workCreatedAt?.getTime() ?? 0],
32
+ queryFn: async () => {
33
+ if (!agentWorkId) {
34
+ return { buckets: [], totals: [] };
35
+ }
36
+
37
+ const metricWindow = workMetricsWindow(new Date(), workCreatedAt);
38
+ return traceClient.getSessionTraceMetrics({
39
+ projectId: projectId ?? "", agentWorkId,
40
+ startTime: timestampFromDate(metricWindow.start),
41
+ endTime: timestampFromDate(metricWindow.end),
42
+ stepSeconds: metricWindow.stepSeconds,
43
+ metricKinds: [...traceMetricKinds],
44
+ });
45
+ },
46
+ enabled: shouldQueryTrace,
47
+ retry: false,
48
+ refetchInterval: shouldQueryTrace ? traceRefreshIntervalMs : false,
49
+ refetchOnWindowFocus: false,
50
+ });
51
+ const latestRefetchRef = useRef(refetch);
52
+ const latestTraceStateRef = useRef({
53
+ enabled,
54
+ agentWorkId: agentWorkId ?? null,
55
+ isWorkLifecyclePending,
56
+ isWorkTerminal,
57
+ });
58
+ const diagnostics = useMemo(() => buildTraceMetricsDiagnostics(data ?? { buckets: [], totals: [] }), [data]);
59
+ const { summary } = diagnostics;
60
+
61
+ useEffect(() => {
62
+ latestRefetchRef.current = refetch;
63
+ latestTraceStateRef.current = {
64
+ enabled,
65
+ agentWorkId: agentWorkId ?? null,
66
+ isWorkLifecyclePending,
67
+ isWorkTerminal,
68
+ };
69
+ }, [enabled, isWorkLifecyclePending, isWorkTerminal, refetch, agentWorkId]);
70
+
71
+ useEffect(() => {
72
+ return () => {
73
+ if (terminalGraceTimerRef.current) {
74
+ clearTimeout(terminalGraceTimerRef.current);
75
+ }
76
+ };
77
+ }, []);
78
+
79
+ useEffect(() => {
80
+ activeTraceSeenRef.current = false;
81
+ terminalGracePollsRemainingRef.current = 0;
82
+ if (terminalGraceTimerRef.current) {
83
+ clearTimeout(terminalGraceTimerRef.current);
84
+ terminalGraceTimerRef.current = null;
85
+ }
86
+ }, [agentWorkId]);
87
+
88
+ useEffect(() => {
89
+ if (!shouldQueryTrace) {
90
+ return;
91
+ }
92
+
93
+ activeTraceSeenRef.current = true;
94
+ terminalKey.current = null;
95
+ terminalGracePollsRemainingRef.current = 0;
96
+ if (terminalGraceTimerRef.current) {
97
+ clearTimeout(terminalGraceTimerRef.current);
98
+ terminalGraceTimerRef.current = null;
99
+ }
100
+ }, [shouldQueryTrace]);
101
+
102
+ useEffect(() => {
103
+ if (!enabled || !agentWorkId || isWorkLifecyclePending || !isWorkTerminal || terminalKey.current === currentKey) {
104
+ return;
105
+ }
106
+
107
+ const shouldGracePoll = activeTraceSeenRef.current;
108
+ terminalKey.current = currentKey;
109
+ if (isError) {
110
+ return;
111
+ }
112
+ void refetch().then((result) => {
113
+ if (!result.isError && shouldGracePoll) {
114
+ scheduleTerminalGracePolling(latestRefetchRef, latestTraceStateRef, terminalGraceTimerRef, terminalGracePollsRemainingRef);
115
+ }
116
+ });
117
+ }, [enabled, isError, isWorkLifecyclePending, isWorkTerminal, currentKey, refetch, agentWorkId]);
118
+
119
+ return { data, error, isError, isLoading, isFetching, dataUpdatedAt, refetch, diagnostics, summary };
120
+ }
121
+
122
+ function scheduleTerminalGracePolling(
123
+ latestRefetchRef: { current: () => Promise<{ isError: boolean }> },
124
+ latestTraceStateRef: {
125
+ current: {
126
+ enabled: boolean;
127
+ agentWorkId: string | null;
128
+ isWorkLifecyclePending: boolean;
129
+ isWorkTerminal: boolean;
130
+ };
131
+ },
132
+ terminalGraceTimerRef: { current: ReturnType<typeof setTimeout> | null },
133
+ terminalGracePollsRemainingRef: { current: number },
134
+ ) {
135
+ if (terminalGraceTimerRef.current) {
136
+ clearTimeout(terminalGraceTimerRef.current);
137
+ }
138
+
139
+ terminalGracePollsRemainingRef.current = terminalTraceGracePollCount;
140
+ const poll = () => {
141
+ if (terminalGracePollsRemainingRef.current <= 0) {
142
+ return;
143
+ }
144
+
145
+ terminalGraceTimerRef.current = setTimeout(() => {
146
+ terminalGraceTimerRef.current = null;
147
+ const state = latestTraceStateRef.current;
148
+ if (!state.enabled || !state.agentWorkId || state.isWorkLifecyclePending || !state.isWorkTerminal) {
149
+ terminalGracePollsRemainingRef.current = 0;
150
+ return;
151
+ }
152
+
153
+ void latestRefetchRef.current().then((result) => {
154
+ if (result.isError) {
155
+ terminalGracePollsRemainingRef.current = 0;
156
+ return;
157
+ }
158
+
159
+ terminalGracePollsRemainingRef.current -= 1;
160
+ poll();
161
+ });
162
+ }, terminalTraceGracePollIntervalMs);
163
+ };
164
+
165
+ poll();
166
+ }
167
+
168
+ export function workMetricsWindow(now: Date, workCreatedAt: Date | null): { start: Date; end: Date; stepSeconds: number } {
169
+ const end = new Date(now.getTime() + 60_000);
170
+ const fallbackStart = new Date(now.getTime() - 30 * 24 * 60 * 60_000);
171
+ const start = workCreatedAt && Number.isFinite(workCreatedAt.getTime())
172
+ ? new Date(workCreatedAt.getTime() - 60_000)
173
+ : fallbackStart;
174
+ return { start, end, stepSeconds: 3600 };
175
+ }
@@ -0,0 +1,49 @@
1
+ export type UserDisplaySource = {
2
+ userId?: string;
3
+ displayName?: string;
4
+ primaryEmail?: string;
5
+ avatarUrl?: string;
6
+ };
7
+
8
+ export function isUnsafeIdentityText(value: string | undefined, userId?: string): boolean {
9
+ const text = value?.trim() ?? "";
10
+ const id = userId?.trim() ?? "";
11
+ if (!text) return false;
12
+ if (id && text === id) return true;
13
+ if (/^oidc:/i.test(text) || text.includes("#")) return true;
14
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) return true;
15
+ if (/^sub[-_:]/i.test(text)) return true;
16
+ return false;
17
+ }
18
+
19
+ export function safeDisplayName(value: string | undefined, userId?: string, fallback = "User"): string {
20
+ const text = value?.trim() ?? "";
21
+ if (!text || isUnsafeIdentityText(text, userId)) {
22
+ return fallback;
23
+ }
24
+ return text;
25
+ }
26
+
27
+ export function displayNameForPerson(person: UserDisplaySource | undefined, fallback = "User"): string {
28
+ return safeDisplayName(person?.displayName, person?.userId, fallback);
29
+ }
30
+
31
+ export function safeEmail(value: string | undefined): string {
32
+ const email = value?.trim() ?? "";
33
+ return email.includes("@") && !isUnsafeIdentityText(email) ? email : "";
34
+ }
35
+
36
+ export function safeAvatarUrl(value: string | undefined): string {
37
+ const url = value?.trim() ?? "";
38
+ if (!url || isUnsafeIdentityText(url)) return "";
39
+ return /^https?:\/\//i.test(url) || url.startsWith("/") ? url : "";
40
+ }
41
+
42
+ export function avatarInitial(name: string, email = "", fallback = "U"): string {
43
+ return (name.trim() || email.trim() || fallback).charAt(0).toUpperCase();
44
+ }
45
+
46
+ /** A User Directory person's name as shown: their display name, else username. */
47
+ export function personName(user: { userId: string; username: string; displayName: string }) {
48
+ return safeDisplayName(user.displayName, user.userId, user.username || "Someone");
49
+ }
@@ -0,0 +1,118 @@
1
+ import { equals } from "@bufbuild/protobuf";
2
+ import { UnifiedMessageKind, UnifiedMessageSchema, type UnifiedMessage } from "@talgent/workbench-contracts/dispatcher/public/v1/unified_message_pb";
3
+
4
+ export interface WorkTurn {
5
+ key: string;
6
+ turnId: string;
7
+ messages: readonly UnifiedMessage[];
8
+ }
9
+
10
+ interface ConversationSnapshot {
11
+ turns: readonly WorkTurn[];
12
+ count: number;
13
+ textBytes: number;
14
+ newUserId?: string;
15
+ olderEvicted: boolean;
16
+ }
17
+
18
+ const emptySnapshot: ConversationSnapshot = { turns: [], count: 0, textBytes: 0, olderEvicted: false };
19
+
20
+ // The view owns a bounded message window. A frame updates only its actual turn;
21
+ // it does not reinterpret or sort the complete transcript on every delivery.
22
+ export class WorkConversation {
23
+ private messages = new Map<string, UnifiedMessage>();
24
+ private turns = new Map<string, WorkTurn>();
25
+ private order: UnifiedMessage[] = [];
26
+ private listeners = new Set<() => void>();
27
+ private snapshot = emptySnapshot;
28
+ private bytes = 0;
29
+
30
+ constructor(private workId: string, private limit = 500, private byteLimit = 8 * 1024 * 1024) {}
31
+ getSnapshot = () => this.snapshot;
32
+ has = (id: string) => this.messages.has(id);
33
+ getOldest = () => this.order[0];
34
+ getNewest = () => this.order.at(-1);
35
+ subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
36
+
37
+ add(messages: readonly UnifiedMessage[], source: "live" | "replay" | "history" | "older") {
38
+ let olderEvicted = source === "older" ? false : this.snapshot.olderEvicted;
39
+ let changed = olderEvicted !== this.snapshot.olderEvicted;
40
+ let newUserId = this.snapshot.newUserId;
41
+ for (const message of messages) {
42
+ // The root Agent's messages only: process diagnostics carry no Agent and stay out.
43
+ if (message.agentWorkId !== this.workId || message.agent !== "" || !message.id || !message.payload.case) continue;
44
+ const previous = this.messages.get(message.id);
45
+ // A history request may finish after a newer live version of the same ID.
46
+ if (previous && (source === "history" || source === "older")) continue;
47
+ if (previous && equals(UnifiedMessageSchema, previous, message)) continue;
48
+ if (previous) this.remove(previous);
49
+ this.messages.set(message.id, message);
50
+ this.bytes += textBytes(message);
51
+ insert(this.order, message);
52
+ const key = turnKey(message);
53
+ const turn = this.turns.get(key);
54
+ const items = turn ? turn.messages.slice() : [];
55
+ insert(items, message);
56
+ this.turns.set(key, { key, turnId: message.turnId, messages: items });
57
+ if (!previous && source === "live" && message.kind === UnifiedMessageKind.USER) newUserId = message.id;
58
+ changed = true;
59
+ }
60
+ if (!changed) return;
61
+ // ponytail: retain one oversized original message intact; a future lazy
62
+ // text-body contract is required to bound that single-message case too.
63
+ while (this.order.length > 1 && (this.order.length > this.limit || this.bytes > this.byteLimit)) {
64
+ this.remove(source === "older" ? this.order[this.order.length - 1] : this.order[0]);
65
+ if (source !== "older") olderEvicted = true;
66
+ }
67
+ const keys = new Set(this.order.map(turnKey));
68
+ this.snapshot = { turns: [...keys].map(key => this.turns.get(key)!), count: this.messages.size, textBytes: this.bytes, newUserId, olderEvicted };
69
+ this.listeners.forEach(listener => listener());
70
+ }
71
+
72
+ private remove(message: UnifiedMessage) {
73
+ this.messages.delete(message.id);
74
+ this.bytes -= textBytes(message);
75
+ const index = this.order.findIndex(item => item.id === message.id);
76
+ if (index >= 0) this.order.splice(index, 1);
77
+ const key = turnKey(message);
78
+ const turn = this.turns.get(key);
79
+ if (!turn) return;
80
+ const messages = turn.messages.filter(item => item.id !== message.id);
81
+ if (messages.length) this.turns.set(key, { ...turn, messages });
82
+ else this.turns.delete(key);
83
+ }
84
+ }
85
+
86
+ function turnKey(message: UnifiedMessage) {
87
+ // A missing native association is displayed independently, never attached
88
+ // to the nearest user input or assigned a fabricated turn identity.
89
+ return message.turnId ? `turn:${message.turnId}` : `message:${message.id}`;
90
+ }
91
+
92
+ function insert(messages: UnifiedMessage[], message: UnifiedMessage) {
93
+ let low = 0, high = messages.length;
94
+ while (low < high) {
95
+ const middle = (low + high) >>> 1;
96
+ if (compare(message, messages[middle]) < 0) high = middle; else low = middle + 1;
97
+ }
98
+ messages.splice(low, 0, message);
99
+ }
100
+
101
+ function compare(a: UnifiedMessage, b: UnifiedMessage) {
102
+ const seconds = (a.timestamp?.seconds ?? 0n) - (b.timestamp?.seconds ?? 0n);
103
+ if (seconds !== 0n) return seconds < 0n ? -1 : 1;
104
+ const nanos = (a.timestamp?.nanos ?? 0) - (b.timestamp?.nanos ?? 0);
105
+ return nanos || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
106
+ }
107
+
108
+ // Account for retained strings without allocating a second serialized body.
109
+ function textBytes(message: UnifiedMessage) {
110
+ let characters = message.id.length + message.agentWorkId.length + message.turnId.length + (message.agent?.length ?? 0);
111
+ const payload = message.payload;
112
+ if (payload.case === "message") {
113
+ for (const block of payload.value.content) characters += block.text.length + block.mimeType.length;
114
+ } else if (payload.case) {
115
+ for (const value of Object.values(payload.value)) if (typeof value === "string") characters += value.length;
116
+ }
117
+ return characters * 2;
118
+ }
@@ -0,0 +1,118 @@
1
+ import { Code, ConnectError } from "@connectrpc/connect";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { WorkFileAccessDisposition, WorkFileKind, type WorkFile } from "@talgent/workbench-contracts/agent/public/v1/work_file_pb";
4
+ import { useCurrentUser } from "./account-service";
5
+ import { workFileClient } from "./client";
6
+ import { NameTakenError } from "./name-taken-error";
7
+ import type { PreviewVerdict } from "../components/preview/preview-verdict";
8
+ import { previewVerdict } from "./preview-verdict";
9
+
10
+ // A Personal Work's files, by their position: Work, kind and name. Its owner
11
+ // and Work Share holders read them; only the owner changes them.
12
+ export type WorkFileRef = Pick<WorkFile, "kind" | "name">;
13
+
14
+ export function workFileKey(file: WorkFileRef) {
15
+ return `${file.kind === WorkFileKind.ARTIFACT ? "artifact" : "attachment"}/${file.name}`;
16
+ }
17
+
18
+ export function workFilesQueryKey(workId: string | undefined, userId?: string) {
19
+ return ["work-files", userId ?? "", workId] as const;
20
+ }
21
+
22
+ export function useWorkFiles(workId: string | undefined) {
23
+ const userId = useCurrentUser().data?.user?.id;
24
+ return useQuery({
25
+ queryKey: workFilesQueryKey(workId, userId),
26
+ queryFn: async ({ signal }) => (await workFileClient.listWorkFiles({ workId: workId! }, { signal })).files,
27
+ enabled: Boolean(workId && userId),
28
+ retry: false,
29
+ refetchOnWindowFocus: false,
30
+ refetchInterval: 5000,
31
+ });
32
+ }
33
+
34
+ /** A short-lived link for one read of the file, saved or shown inline. */
35
+ export async function workFileAccessLink(workId: string, file: WorkFileRef, download: boolean) {
36
+ const resp = await workFileClient.createWorkFileAccessLink({
37
+ workId, kind: file.kind, name: file.name,
38
+ disposition: download ? WorkFileAccessDisposition.DOWNLOAD : WorkFileAccessDisposition.PREVIEW,
39
+ });
40
+ if (!resp.accessUrl) throw new Error("The file link is missing.");
41
+ return resp.accessUrl;
42
+ }
43
+
44
+ export function useDownloadWorkFile(workId: string) {
45
+ return useMutation({
46
+ mutationFn: async (file: WorkFileRef) => {
47
+ const url = await workFileAccessLink(workId, file, true);
48
+ window.open(url, "_blank", "noopener,noreferrer");
49
+ return url;
50
+ },
51
+ });
52
+ }
53
+
54
+ /** How Storage shows the file, through Agent. */
55
+ export async function workFilePreview(workId: string, file: WorkFileRef): Promise<PreviewVerdict> {
56
+ return previewVerdict((await workFileClient.getWorkFilePreview({ workId, kind: file.kind, name: file.name })).preview);
57
+ }
58
+
59
+ /** Asks for the file's Preview Copy when it needs one. */
60
+ export async function requestWorkFilePreview(workId: string, file: WorkFileRef): Promise<PreviewVerdict> {
61
+ return previewVerdict((await workFileClient.requestWorkFilePreview({ workId, kind: file.kind, name: file.name })).preview);
62
+ }
63
+
64
+ // Creates the Attachment, or replaces the one of the same name when replace
65
+ // is set. Storage never renames or replaces on its own: a taken name is
66
+ // refused with NameTakenError.
67
+ export async function uploadWorkAttachment(workId: string, file: File, replace = false, fetchImpl: typeof fetch = fetch) {
68
+ let uploadUrl: string;
69
+ try {
70
+ // An unknown type stays undeclared, so Storage infers it from the name.
71
+ uploadUrl = (await workFileClient.createWorkAttachmentUpload({ workId, name: file.name, replace, contentType: file.type })).uploadUrl;
72
+ } catch (error) {
73
+ if (error instanceof ConnectError && error.code === Code.AlreadyExists) throw new NameTakenError(file.name);
74
+ throw error;
75
+ }
76
+ if (!uploadUrl) throw new Error("upload target is missing");
77
+ const upload = await fetchImpl(uploadUrl, { method: "PUT", headers: file.type ? { "content-type": file.type } : undefined, body: file });
78
+ if (upload.status === 409) throw new NameTakenError(file.name);
79
+ if (!upload.ok) throw new Error(`upload failed: ${upload.status}`);
80
+ return file.name;
81
+ }
82
+
83
+ export function useUploadWorkAttachment(workId: string | undefined) {
84
+ const queryClient = useQueryClient();
85
+ const userId = useCurrentUser().data?.user?.id;
86
+ return useMutation({
87
+ mutationFn: ({ file, replace = false }: { file: File; replace?: boolean }) => {
88
+ if (!workId) throw new Error("The Work is required.");
89
+ return uploadWorkAttachment(workId, file, replace);
90
+ },
91
+ onSuccess: () => { void queryClient.invalidateQueries({ queryKey: workFilesQueryKey(workId, userId) }); },
92
+ });
93
+ }
94
+
95
+ // The owner's changes to the Work's files; each refreshes the file list.
96
+ function useWorkFileMutation<T, R>(workId: string, change: (input: T) => Promise<R>) {
97
+ const queryClient = useQueryClient();
98
+ const userId = useCurrentUser().data?.user?.id;
99
+ return useMutation({ mutationFn: change, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: workFilesQueryKey(workId, userId) }); } });
100
+ }
101
+
102
+ export function useRenameWorkAttachment(workId: string) {
103
+ return useWorkFileMutation(workId, ({ name, newName }: { name: string; newName: string }) => workFileClient.renameWorkAttachment({ workId, name, newName }));
104
+ }
105
+
106
+ // Deleting a file ends its share links.
107
+ export function useDeleteWorkFile(workId: string) {
108
+ return useWorkFileMutation(workId, (file: WorkFileRef) => workFileClient.deleteWorkFile({ workId, kind: file.kind, name: file.name }));
109
+ }
110
+
111
+ export function useCreateWorkFileShareLink(workId: string) {
112
+ return useWorkFileMutation(workId, async (file: WorkFileRef) => (await workFileClient.createWorkFileShareLink({ workId, kind: file.kind, name: file.name })).shareLink);
113
+ }
114
+
115
+ // Stopping sharing ends every Share Link of the file.
116
+ export function useStopWorkFileSharing(workId: string) {
117
+ return useWorkFileMutation(workId, (file: WorkFileRef) => workFileClient.stopWorkFileSharing({ workId, kind: file.kind, name: file.name }));
118
+ }
@@ -0,0 +1,38 @@
1
+ import { create } from "@bufbuild/protobuf";
2
+ import { ModelVendor, WorkModelOptionSchema, type WorkModelOption, type WorkModelSelection } from "@talgent/workbench-contracts/agent/public/v1/model_pb";
3
+
4
+ // What a Work's conversation uses for its model: a Model Configuration the
5
+ // platform provides, or, on a Managed Computer, the machine's own Claude login.
6
+
7
+ /** A model a new Work may start on. */
8
+ export type StartModel = { configurationId: string; machineLogin: boolean; name: string; vendor: ModelVendor; thinkingLevels: string[]; defaultThinkingLevel: string };
9
+
10
+ export const machineLoginName = "This machine's Claude login";
11
+
12
+ /** The Agent Harness's thinking levels. The machine's Claude login has no
13
+ * Model Template, so it offers all of them and none is the default. */
14
+ const harnessThinkingLevels = ["low", "medium", "high", "xhigh", "max"];
15
+
16
+ export const machineLoginModel: StartModel = { configurationId: "", machineLogin: true, name: machineLoginName, vendor: ModelVendor.ANTHROPIC, thinkingLevels: harnessThinkingLevels, defaultThinkingLevel: "" };
17
+
18
+ /** The models a new Work may start on its Computer: the provided Model
19
+ * Configurations, and on a Managed Computer first its own Claude login, the
20
+ * default there. */
21
+ export function startModels(provided: WorkModelOption[], managed: boolean): StartModel[] {
22
+ const configurations = provided.map(option => ({ configurationId: option.configurationId, machineLogin: false, name: option.name, vendor: option.vendor, thinkingLevels: option.thinkingLevels, defaultThinkingLevel: option.defaultThinkingLevel }));
23
+ return managed ? [machineLoginModel, ...configurations] : configurations;
24
+ }
25
+
26
+ /** A start model's identity among the choices. */
27
+ export const startModelKey = (model: StartModel) => model.machineLogin ? "machine-login" : model.configurationId;
28
+
29
+ /** The model selection a start request carries. */
30
+ export const startModelSelection = (model: StartModel) => model.machineLogin ? { machineLogin: true } : { configurationId: model.configurationId };
31
+
32
+ const machineLoginOption = create(WorkModelOptionSchema, { name: machineLoginName, vendor: machineLoginModel.vendor, thinkingLevels: harnessThinkingLevels });
33
+
34
+ /** How a Work's model is presented: the machine's Claude login, or its Model
35
+ * Configuration as currently provided, none when withdrawn. */
36
+ export function workModelOption(model?: WorkModelSelection, option?: WorkModelOption): WorkModelOption | undefined {
37
+ return model?.machineLogin ? machineLoginOption : option;
38
+ }
@@ -0,0 +1,23 @@
1
+ import type { StreamDiagnosticFrame } from "@talgent/workbench-contracts/streamgateway/public/v1/streamgateway_pb";
2
+ import { notify } from "./notifications";
3
+
4
+ // Model provider refusals the Harness or a SubAgent recovers from are told live
5
+ // as notices, never kept in the Work Message Stream. Reasons mirror the
6
+ // Dispatcher's command.Notice* vocabulary documented on StreamDiagnosticFrame;
7
+ // delivery diagnostics are connection state and stay silent.
8
+ const providerNotices: Record<string, { title: string; description: string }> = {
9
+ model_rate_limited: { title: "Model provider rate limited", description: "The model provider is limiting requests for this Work." },
10
+ model_unavailable: { title: "Model provider unavailable", description: "The model provider could not handle a request for this Work." },
11
+ };
12
+
13
+ export function notifyWorkNotice(workId: string, diagnostic: StreamDiagnosticFrame) {
14
+ // A reply delivered after its request closed, as when its turn was cancelled.
15
+ if (diagnostic.reason === "interaction_closed") {
16
+ notify(`interaction-closed:${workId}`, "warning", "Request already closed", diagnostic.message || "The request you answered had already closed, so your answer was not used.");
17
+ return;
18
+ }
19
+ const notice = providerNotices[diagnostic.reason];
20
+ if (!notice) return;
21
+ // One notice per Work: a burst of refusals replaces it instead of stacking.
22
+ notify(`model-provider:${workId}`, "warning", notice.title, diagnostic.message || notice.description);
23
+ }
@@ -0,0 +1,41 @@
1
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
2
+ import type { Timestamp } from "@bufbuild/protobuf/wkt";
3
+ export interface WorkProgress {
4
+ updatedAt: Date;
5
+ items: Array<{ content: string; status: string; activeForm?: string }>;
6
+ }
7
+
8
+ interface ProtoWorkProgressLike {
9
+ items?: Array<{
10
+ content?: string;
11
+ status?: string;
12
+ activeForm?: string;
13
+ }>;
14
+ updatedAt?: Timestamp;
15
+ }
16
+
17
+ export function adaptWorkProgress(progress: ProtoWorkProgressLike | undefined): WorkProgress | null {
18
+ if (!progress?.items || progress.items.length === 0) {
19
+ return null;
20
+ }
21
+
22
+ const items = progress.items.flatMap((item): WorkProgress["items"] => {
23
+ if (!item.content) {
24
+ return [];
25
+ }
26
+ return [{
27
+ content: item.content,
28
+ status: item.status || "pending",
29
+ ...(item.activeForm ? { activeForm: item.activeForm } : {}),
30
+ }];
31
+ });
32
+
33
+ if (items.length === 0) {
34
+ return null;
35
+ }
36
+
37
+ return {
38
+ updatedAt: progress.updatedAt ? timestampDate(progress.updatedAt) : new Date(0),
39
+ items,
40
+ };
41
+ }