@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,82 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { Alert, CloseButton } from "@heroui/react";
4
+ import { PluginPreparationStatus, type WorkPluginResult } from "@talgent/workbench-contracts/agent/public/v1/plugin_pb";
5
+ import type { WorkStartupRepository } from "@talgent/workbench-contracts/agent/public/v1/work_pb";
6
+ import { MessageInput, type MessageInputProps } from "../chat/message-input";
7
+ import type { SlashCandidate } from "../composer/use-slash-completion";
8
+ import { agentWorkClient } from "../../lib/client";
9
+ import { pluginDisplayName } from "../../lib/marketplace-plugins";
10
+ import { notify } from "../../lib/notifications";
11
+
12
+ // Safe reason codes of Plugin installation and loading; unknown codes are
13
+ // never shown raw. A failed plugin degrades the Work, so no retry is offered.
14
+ const reasons: Record<string, string> = {
15
+ installation_failed: "Couldn’t be installed.",
16
+ marketplace_unavailable: "The catalog was unavailable, so it wasn’t installed.",
17
+ preparation_failed: "Couldn’t be set up.",
18
+ not_loaded: "Installed but not loaded by the agent.",
19
+ version_mismatch: "A different version was loaded than the one selected.",
20
+ };
21
+ function pluginFailure(result: WorkPluginResult) {
22
+ if (result.installation === PluginPreparationStatus.FAILED) return reasons[result.installationReason] ?? "Couldn’t be loaded.";
23
+ if (result.loading === PluginPreparationStatus.FAILED) return reasons[result.loadingReason] ?? "Couldn’t be loaded.";
24
+ return "";
25
+ }
26
+
27
+ // Why a selected repository was not checked out; the Work continues without it.
28
+ const checkoutReasons: Record<string, string> = {
29
+ timed_out: "Checkout didn’t finish in time.",
30
+ checkout_unavailable: "The repository couldn’t be cloned.",
31
+ branch_unavailable: "The selected base branch wasn’t found.",
32
+ target_conflict: "Another folder already uses its place in the workspace.",
33
+ preparation_incomplete: "Checkout didn’t finish before the Work started.",
34
+ };
35
+
36
+ // The running Work's composer. Slash candidates are exactly what the Harness
37
+ // reported, listed flat: the runtime gives them no reliable category. Plugins
38
+ // that failed to install or load and repositories not checked out are reported
39
+ // above it while the Work continues.
40
+ export function WorkPromptBar({ repositories = [], ...props }: MessageInputProps & { workId: string; active: boolean; repositories?: WorkStartupRepository[] }) {
41
+ const { workId, active } = props;
42
+ const [dismissed, setDismissed] = useState<string[]>([]);
43
+ const capabilities = useQuery({ queryKey: ["work-capabilities", workId], queryFn: () => agentWorkClient.getWorkCapabilities({ workId }), enabled: active, retry: false, refetchInterval: active ? 15_000 : false });
44
+ const results = useQuery({ queryKey: ["work-plugin-results", workId], queryFn: () => agentWorkClient.getWorkPluginResults({ workId }), retry: false, refetchInterval: active ? 30_000 : false });
45
+ const commands = capabilities.data?.observed ? capabilities.data.commands : [];
46
+ // A namespaced name marks a Plugin Skill for the in-text highlight only.
47
+ const candidates: SlashCandidate[] = commands.map(command => ({ name: command.name, description: command.description, argumentHint: command.argumentHint, builtIn: !command.name.includes(":") }));
48
+ useCommandChanges(workId, commands.map(command => command.name), Boolean(capabilities.data?.observed));
49
+ const failures = (results.data?.plugins ?? []).map(result => ({ slug: result.slug, reason: pluginFailure(result) })).filter(item => item.reason && !dismissed.includes(item.slug));
50
+ const missing = repositories.filter(repository => repository.checkoutStatus === "not_checked_out" && !dismissed.includes(`repository:${repository.repositoryId}`));
51
+ const dismiss = (key: string) => setDismissed(current => [...current, key]);
52
+ const notices = failures.length || missing.length ? <div className="grid gap-2 mb-2">
53
+ {failures.map(failure => <DegradationNotice key={failure.slug} name={pluginDisplayName(failure.slug)} title={`${pluginDisplayName(failure.slug)} isn’t available in this Work`} description={`${failure.reason} Other plugins work as usual.`} onDismiss={() => dismiss(failure.slug)} />)}
54
+ {missing.map(repository => { const name = repository.name || repository.repositoryId; const reason = checkoutReasons[repository.checkoutReason];
55
+ return <DegradationNotice key={repository.repositoryId} name={name} title={`${name} wasn’t checked out`} description={`${reason ? `${reason} ` : ""}The Work continues without it.`} onDismiss={() => dismiss(`repository:${repository.repositoryId}`)} />; })}
56
+ </div> : undefined;
57
+ return <MessageInput {...props} notices={notices} slash={{ candidates, observed: Boolean(capabilities.data?.observed) }} />;
58
+ }
59
+
60
+ // A startup step that degraded: the Work runs without what it names.
61
+ function DegradationNotice({ name, title, description, onDismiss }: { name: string; title: string; description: string; onDismiss: () => void }) {
62
+ return <Alert status="warning">
63
+ <Alert.Indicator /><Alert.Content><Alert.Title>{title}</Alert.Title><Alert.Description>{description}</Alert.Description></Alert.Content>
64
+ <CloseButton aria-label={`Dismiss ${name} notice`} onPress={onDismiss} />
65
+ </Alert>;
66
+ }
67
+
68
+ // A notice when the Harness reports a changed command set, not on first load.
69
+ function useCommandChanges(workId: string, names: string[], observed: boolean) {
70
+ const previous = useRef<string[] | null>(null);
71
+ const key = names.join("\n");
72
+ useEffect(() => {
73
+ if (!observed) return;
74
+ const current = key ? key.split("\n") : [];
75
+ const before = previous.current;
76
+ previous.current = current;
77
+ if (!before) return;
78
+ const added = current.filter(name => !before.includes(name));
79
+ if (added.length) notify(`commands:${workId}`, "default", added.length === 1 ? "New command available" : `${added.length} new commands available`, `${added.map(name => `/${name}`).join(", ")} can be used in this Work. Type / to use it.`);
80
+ else if (current.length !== before.length) notify(`commands:${workId}`, "default", "Commands updated", "Type / to see what this Work can run now.");
81
+ }, [key, observed, workId]);
82
+ }
@@ -0,0 +1,72 @@
1
+ import { useId, useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { Alert, Button } from "@heroui/react";
4
+ import { ChevronDown } from "lucide-react";
5
+ import { InteractionPhase, UnifiedMessageKind, type UnifiedMessage } from "@talgent/workbench-contracts/dispatcher/public/v1/unified_message_pb";
6
+ import { useWorkStreamSource } from "../../lib/work-stream-source";
7
+ import { parseWorkQuestions } from "../../lib/work-questions";
8
+ import { ToolMessageDetail } from "./tool-message-detail";
9
+
10
+ export function WorkQuestionRecord({ message, action, output, open, onToggle }: {
11
+ message: UnifiedMessage; action?: UnifiedMessage; output?: UnifiedMessage; open: boolean; onToggle: () => void;
12
+ }) {
13
+ const contentId = useId();
14
+ const [showOutput, setShowOutput] = useState(false);
15
+ if (message.payload.case !== "interaction") return null;
16
+ const question = message.payload.value;
17
+ let answers: Record<string, string> = {};
18
+ let valid = true;
19
+ try {
20
+ const value: unknown = JSON.parse(question.responseJson || "{}");
21
+ if (value && typeof value === "object" && !Array.isArray(value) && Object.values(value).every(answer => typeof answer === "string")) answers = value as Record<string, string>;
22
+ else valid = false;
23
+ } catch { valid = false; }
24
+ const answered = Object.keys(answers).length;
25
+ const asked = question.questionCount ? `Asked ${question.questionCount} ${question.questionCount === 1 ? "question" : "questions"}` : "Asked questions";
26
+ const resolved = valid && (question.phase === InteractionPhase.RESPONDED || question.phase === InteractionPhase.SKIPPED);
27
+ const status = resolved ? `Answered ${answered}${question.questionCount ? `, skipped ${Math.max(0, question.questionCount - answered)}` : ""}`
28
+ : !valid ? "Response unavailable" : question.phase === InteractionPhase.FAILED ? "Response failed" : question.phase === InteractionPhase.CANCELLED ? "Cancelled" : "Waiting for response";
29
+ const failedOutput = output?.payload.case === "observation" && output.payload.value.isError ? output : undefined;
30
+ const cancelled = question.phase === InteractionPhase.CANCELLED || question.phase === InteractionPhase.SKIPPED;
31
+ return <div className="wm-question-record" id={`wm-message-${message.id}`} data-message-id={message.id}>
32
+ <Button variant="ghost" className="wm-tool-summary" aria-expanded={open} aria-controls={contentId} onPress={onToggle}>{asked} · {status}<ChevronDown size={14} aria-hidden="true" /></Button>
33
+ {open && <div id={contentId}>
34
+ {question.error && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>{question.error}</Alert.Description></Alert.Content></Alert>}
35
+ {failedOutput && <Button size="sm" variant="ghost" aria-expanded={showOutput} onPress={() => setShowOutput(!showOutput)}>{showOutput ? "Show questions" : cancelled ? "Show tool output" : "Show tool error"}</Button>}
36
+ {failedOutput && showOutput ? <ToolMessageDetail workId={message.agentWorkId} messageId={failedOutput.id} /> : <QuestionAnswers message={message} action={action} answers={answers} resolved={resolved} />}
37
+ </div>}
38
+ </div>;
39
+ }
40
+
41
+ function QuestionAnswers({ message, action, answers, resolved }: {
42
+ message: UnifiedMessage; action?: UnifiedMessage; answers: Record<string, string>; resolved: boolean;
43
+ }) {
44
+ const streamClient = useWorkStreamSource();
45
+ const toolCallId = message.payload.case === "interaction" ? message.payload.value.toolCallId : "";
46
+ const input = useQuery({
47
+ queryKey: ["question-input", message.agentWorkId, toolCallId, action?.id],
48
+ queryFn: async ({ signal }) => {
49
+ // Only the expanded record owns its original input. It is released on
50
+ // collapse; each transport page stays within the detail API limit.
51
+ const original = action ?? (await streamClient.listMessagesBySession({ agentWorkId: message.agentWorkId, agent: message.agent, toolCallIds: [toolCallId], kinds: [UnifiedMessageKind.ACTION], limit: 1 }, { signal })).messages[0];
52
+ if (!original) throw new Error("Original questions unavailable");
53
+ let text = "", offset = 0;
54
+ for (;;) {
55
+ const page = await streamClient.getMessageDetail({ agentWorkId: message.agentWorkId, messageId: original.id, offset, limit: 65536 }, { signal });
56
+ text += page.text;
57
+ if (page.nextOffset >= page.totalLength) break;
58
+ if (page.nextOffset <= offset) throw new Error("Incomplete question input");
59
+ offset = page.nextOffset;
60
+ }
61
+ const parsed = parseWorkQuestions(text);
62
+ if (!parsed) throw new Error("Original questions unavailable");
63
+ return parsed.questions;
64
+ },
65
+ gcTime: 0, staleTime: Infinity, retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false,
66
+ });
67
+ return <>
68
+ {input.isPending && <p className="sr-only" role="status">Loading questions…</p>}
69
+ {input.isError && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Could not load original questions.</Alert.Description></Alert.Content><Button variant="ghost" size="sm" onPress={() => void input.refetch()}>Retry</Button></Alert>}
70
+ {input.data && <dl>{input.data.map((question, index) => <div key={index}><dt>{question.question}</dt><dd>{Object.hasOwn(answers, question.question) ? answers[question.question] : resolved ? "Skipped" : "No answer"}</dd></div>)}</dl>}
71
+ </>;
72
+ }
@@ -0,0 +1,24 @@
1
+ import { Alert, Button } from "@heroui/react";
2
+ import { CircleOff, CloudOff } from "lucide-react";
3
+ import "./conversation-unavailable.css";
4
+
5
+ /** Shown in place of the conversation when the Work's state cannot be read:
6
+ * the authority says it does not exist, or the first read failed. */
7
+ export function WorkSnapshotUnavailable({ notFound, onRetry }: { notFound: boolean; onRetry: () => void }) {
8
+ if (notFound) return <p className="wca-unavailable" role="status"><CircleOff size={20} aria-hidden="true" />This Work no longer exists</p>;
9
+ return <div className="wca-unavailable" role="alert">
10
+ <CloudOff size={20} aria-hidden="true" />
11
+ <span>This Work's state could not be loaded</span>
12
+ <Button size="sm" variant="ghost" onPress={onRetry}>Retry</Button>
13
+ </div>;
14
+ }
15
+
16
+ /** The page keeps the last state it read and says how old it is until a
17
+ * refresh succeeds again. */
18
+ export function WorkSnapshotStaleNotice({ lastUpdatedAt, onRetry }: { lastUpdatedAt: Date; onRetry: () => void }) {
19
+ return <Alert status="warning" role="status">
20
+ <Alert.Indicator />
21
+ <Alert.Content><Alert.Description>Showing the state from {lastUpdatedAt.toLocaleTimeString()}. Updates are failing; actions are checked against the current Work before they apply.</Alert.Description></Alert.Content>
22
+ <Button size="sm" variant="ghost" onPress={onRetry}>Refresh</Button>
23
+ </Alert>;
24
+ }
@@ -0,0 +1,54 @@
1
+ import { createPortal } from "react-dom";
2
+ import { Alert, Button, Dropdown, Label, Modal, Tooltip } from "@heroui/react";
3
+ import { Archive, Circle, Play, Power, RotateCcw } from "lucide-react";
4
+ import { useReadingArea } from "../shell/reading-context";
5
+ import { workPresentation, workStatusName } from "../../lib/work-state-presentation";
6
+ import type { useWorkLifecycleControls } from "../../hooks/use-work-lifecycle-controls";
7
+
8
+ interface WorkStatusControlProps {
9
+ workLifecycle: string;
10
+ allowedActions: string[];
11
+ /** Why the Work stopped, when it is Stopped. */
12
+ stateReason?: string;
13
+ /** When the Work was disconnected; absent while observed. */
14
+ disconnectedSince?: Date;
15
+ disabled?: boolean;
16
+ controls: ReturnType<typeof useWorkLifecycleControls>;
17
+ }
18
+
19
+ export function WorkStatusControl({ workLifecycle, allowedActions, stateReason, disconnectedSince, disabled = false, controls }: WorkStatusControlProps) {
20
+ const { archiveOpen, setArchiveOpen, pending: actionPending, error: actionError, stop, resume, restart, archive } = controls;
21
+ const controlsPending = disabled || actionPending;
22
+ const { workStatusToolbar } = useReadingArea();
23
+ const archiveDialog = <Modal><Modal.Backdrop isOpen={archiveOpen} onOpenChange={setArchiveOpen} isDismissable={!actionPending} isKeyboardDismissDisabled={actionPending}><Modal.Container placement="center" size="sm"><Modal.Dialog>
24
+ <Modal.Header><Modal.Heading>Archive work?</Modal.Heading></Modal.Header>
25
+ <Modal.Body><p>This closes the work and removes its working files. Published files and your home are kept.</p>{actionError && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>{actionError}</Alert.Description></Alert.Content></Alert>}</Modal.Body>
26
+ <Modal.Footer><Button autoFocus variant="secondary" isDisabled={actionPending} onPress={() => setArchiveOpen(false)}>Cancel</Button><Button variant="danger" isPending={actionPending} onPress={() => void archive()}>Archive work</Button></Modal.Footer>
27
+ </Modal.Dialog></Modal.Container></Modal.Backdrop></Modal>;
28
+
29
+ const status = workPresentation({ state: workLifecycle, stateReason, disconnectedSince });
30
+ const StatusIcon = status?.icon ?? Circle;
31
+ const label = status?.label ?? "Unknown state";
32
+ const name = workStatusName("Work: ", { label, description: status?.description });
33
+ const tooltip = status?.description ? <><p>{label}</p><p className="text-muted">{status.description}</p></> : label;
34
+ const actions = [
35
+ { id: "stop", label: "Stop work", icon: Power },
36
+ { id: "resume", label: "Resume work", icon: Play },
37
+ { id: "restart", label: "Restart work", icon: RotateCcw },
38
+ { id: "archive", label: "Archive work", icon: Archive },
39
+ ].filter(action => allowedActions.includes(action.id));
40
+ const workStatus = workStatusToolbar && createPortal(actions.length ? <Dropdown>
41
+ <Tooltip><Button size="sm" variant="ghost" isIconOnly aria-label={name} isDisabled={controlsPending}><StatusIcon size={17} aria-hidden="true" /></Button><Tooltip.Content className="max-w-72">{tooltip}</Tooltip.Content></Tooltip>
42
+ <Dropdown.Popover placement="bottom end"><Dropdown.Menu aria-label="Work actions" onAction={key => {
43
+ if (controlsPending || !allowedActions.includes(String(key))) return;
44
+ if (key === "stop") void stop();
45
+ else if (key === "resume") void resume();
46
+ else if (key === "restart") void restart();
47
+ else if (key === "archive") setArchiveOpen(true);
48
+ }}>
49
+ {actions.map(({ id, label, icon: Icon }) => <Dropdown.Item key={id} id={id} textValue={label} variant={id === "archive" ? "danger" : undefined} isDisabled={controlsPending}><Icon size={17} aria-hidden="true" /><Label>{label}</Label></Dropdown.Item>)}
50
+ </Dropdown.Menu></Dropdown.Popover>
51
+ </Dropdown> : <Tooltip><Tooltip.Trigger className="wm-work-status" role="status" tabIndex={0} aria-label={name}><StatusIcon size={17} aria-hidden="true" /></Tooltip.Trigger><Tooltip.Content className="max-w-72">{tooltip}</Tooltip.Content></Tooltip>, workStatusToolbar);
52
+
53
+ return <>{workStatus}{archiveDialog}</>;
54
+ }
@@ -0,0 +1,33 @@
1
+ import { Code, ConnectError } from "@connectrpc/connect";
2
+ import { ComputerOutdatedSchema, SandboxUnavailableSchema } from "@talgent/workbench-contracts/agent/public/v1/harness_pb";
3
+
4
+ /** Why a start failed; computer is the one it was started on. A full Managed
5
+ * Computer, one without Claude Code's sandbox, or one below the Minimum
6
+ * Version is named with what its owner can do about it. */
7
+ export function startWorkErrorMessage(err: unknown, computer?: { name: string; managed: boolean }) {
8
+ if (err instanceof ConnectError && err.code === Code.ResourceExhausted) {
9
+ if (computer?.managed) return `${computer.name} already runs as many Works as its Capacity allows. Try again once one ends, or raise its Capacity or let it queue runs on the Computers page in your account menu.`;
10
+ return "Execution capacity is unavailable. Try starting again when capacity is free.";
11
+ }
12
+ const message = err instanceof Error ? err.message : "Failed to start work.";
13
+ if (err instanceof ConnectError && err.findDetails(SandboxUnavailableSchema).length > 0) {
14
+ return `${computer?.name ?? "This Computer"} can’t run Works in Claude Code’s sandbox (native Windows has none; on Linux install bubblewrap and socat). Turn the sandbox off or choose another Computer.`;
15
+ }
16
+ if (err instanceof ConnectError && err.findDetails(ComputerOutdatedSchema).length > 0) {
17
+ return `${computer?.name ?? "This Computer"} runs a Talgent Computer older than Talgent supports, so it takes no new Works. It upgrades itself while connected; if it stays outdated, reinstall it with the command on the Computers page in your account menu.`;
18
+ }
19
+ if (/github_(reconnect_required|authorization_revoked)/i.test(message)) {
20
+ return "GitHub authorization needs to be renewed in Marketplace › Providers before repository checkout can start.";
21
+ }
22
+ if (err instanceof ConnectError && err.code === Code.FailedPrecondition && isRepositoryCheckoutPreconditionMessage(message)) {
23
+ return `GitHub repository checkout cannot start (failed_precondition): ${message}`;
24
+ }
25
+ if (/\[failed_precondition\]/i.test(message) && isRepositoryCheckoutPreconditionMessage(message)) {
26
+ return `GitHub repository checkout cannot start: ${message}`;
27
+ }
28
+ return message;
29
+ }
30
+
31
+ function isRepositoryCheckoutPreconditionMessage(message: string) {
32
+ return /(scm|github|checkout|repository)/i.test(message);
33
+ }
@@ -0,0 +1,116 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { Button, ListBox } from "@heroui/react";
3
+ import type { PluginSummary } from "@talgent/workbench-contracts/marketplace/public/v1/marketplace_pb";
4
+ import { AlertTriangle, ArrowLeft, Plug, Puzzle, Settings2 } from "lucide-react";
5
+ import { providerName, serviceLabel, useConnectProvider } from "../../lib/marketplace-connections";
6
+ import { pluginDisplayName, pluginPresentation } from "../../lib/marketplace-plugins";
7
+ import { draftsFrom } from "../../lib/plugin-parameters";
8
+ import type { ConnectorRow, LoadoutRow } from "../../lib/work-start-loadout";
9
+ import type { WorkStartDraft } from "../../lib/work-start-draft";
10
+ import { ParameterFields } from "../marketplace/parameter-fields";
11
+ import { CheckRow, ScopeNote, Search, TrayPopover } from "./work-start-tray";
12
+
13
+ type Update = (change: Partial<WorkStartDraft> | ((draft: WorkStartDraft) => WorkStartDraft)) => void;
14
+ type Entry = { key: string; category: string; installed: boolean; title: string; secondary: string; warn?: boolean; checked: boolean; disabled?: boolean; trailing?: ReactNode; onChange: () => void };
15
+
16
+ const installedSection = "installed";
17
+ const byCategory = (a: string, b: string) => a === "Other" ? 1 : b === "Other" ? -1 : a.localeCompare(b);
18
+
19
+ // Plugins and Connectors browse by catalog category. The rail opens on
20
+ // Installed — the starter's installations and required entries — then every
21
+ // category; checking an entry anywhere includes it in this Work, so browsing
22
+ // and adding are one list. A search spans every category.
23
+ function CatalogPicker({ noun, entries }: { noun: string; entries: Entry[] }) {
24
+ const [section, setSection] = useState(installedSection);
25
+ const [query, setQuery] = useState("");
26
+ const categories = [...new Set(entries.map(entry => entry.category))].sort(byCategory);
27
+ const searching = Boolean(query.trim());
28
+ const shown = searching ? entries.filter(entry => `${entry.title} ${entry.secondary} ${entry.category}`.toLowerCase().includes(query.trim().toLowerCase()))
29
+ : entries.filter(entry => section === installedSection ? entry.installed : entry.category === section);
30
+ const count = (predicate: (entry: Entry) => boolean) => entries.filter(predicate).length;
31
+ return <>
32
+ <Search label={`Search ${noun}`} value={query} onChange={setQuery} />
33
+ <div className="wst-catalog" data-searching={searching || undefined}>
34
+ {!searching && <ListBox aria-label={`${noun} categories`} selectionMode="single" disallowEmptySelection selectedKeys={[section]} className="wst-rail"
35
+ onSelectionChange={keys => { if (keys !== "all") { const next = [...keys][0]; if (next !== undefined) setSection(String(next)); } }}>
36
+ {[[installedSection, "Installed", count(entry => entry.installed)] as const, ...categories.map(name => [name, name, count(entry => entry.category === name)] as const)].map(([id, name, total]) =>
37
+ <ListBox.Item key={id} id={id} textValue={name} className="wst-rail-item"><span className="wst-rail-name">{name}</span><span className="wpb-muted wst-count">{total}</span></ListBox.Item>)}
38
+ </ListBox>}
39
+ <div className="wst-rows wst-pane" role="group" aria-label={searching ? `Matching ${noun}` : section === installedSection ? "Installed" : section}>
40
+ {shown.length ? shown.map(entry => <CheckRow key={entry.key} label={`Include ${entry.title} in this Work`} checked={entry.checked} disabled={entry.disabled} struck={entry.installed && !entry.checked} title={entry.title}
41
+ secondary={searching ? `${entry.category} · ${entry.secondary}` : entry.secondary} warn={entry.warn} trailing={entry.trailing} onChange={entry.onChange} />)
42
+ : <p className="wpb-empty" role="status">{searching ? `No ${noun} match.` : `No ${noun} here yet.`}</p>}
43
+ </div>
44
+ </div>
45
+ <ScopeNote />
46
+ </>;
47
+ }
48
+
49
+ const summary = (row: LoadoutRow) => [row.skills.length ? `${row.skills.length} ${row.skills.length === 1 ? "skill" : "skills"}` : "", row.agents.length ? `${row.agents.length} ${row.agents.length === 1 ? "agent" : "agents"}` : ""].filter(Boolean).join(" · ") || "No skills or agents";
50
+ const rowState = (row: LoadoutRow) => row.problem || (row.difference === "removed" ? "Off for this Work" : row.edited.length ? `${row.edited.length} ${row.edited.length === 1 ? "setting" : "settings"} changed for this Work` : row.difference === "required" ? "Always included" : summary(row));
51
+ const hasSettings = (release?: PluginSummary) => Boolean(release && Object.keys(release.parameters).length);
52
+
53
+ // Plugins of this Work as differences from the defaults. `view` is a Plugin's
54
+ // slug while its settings for this Work are open.
55
+ export function PluginsMenu({ rows, catalog, draft, update, disabled, open, onOpenChange, view, onViewChange, errors }: {
56
+ rows: LoadoutRow[]; catalog: PluginSummary[]; draft: WorkStartDraft; update: Update; disabled: boolean; open: boolean; onOpenChange: (open: boolean) => void;
57
+ view: string; onViewChange: (view: string) => void; errors: Record<string, Record<string, string>>;
58
+ }) {
59
+ const included = rows.filter(row => row.included);
60
+ const changes = rows.filter(row => row.difference === "added" || row.difference === "removed" || row.edited.length).length;
61
+ const toggle = (row: LoadoutRow) => update(current => {
62
+ const plugins = { ...current.plugins };
63
+ if (row.difference === "added") delete plugins[row.slug];
64
+ else if (row.included) plugins[row.slug] = { include: false, values: plugins[row.slug]?.values ?? {} };
65
+ else plugins[row.slug] = { values: plugins[row.slug]?.values ?? {} };
66
+ // A Startup Agent provided only by a removed Plugin resets to the default.
67
+ const agent = row.included && row.agents.some(item => item.name === current.agent) ? "" : current.agent;
68
+ return { ...current, plugins, agent };
69
+ });
70
+ const add = (slug: string) => update(current => ({ ...current, plugins: { ...current.plugins, [slug]: { include: true, values: {} } } }));
71
+ const detail = rows.find(row => row.slug === view);
72
+ const entries: Entry[] = [
73
+ ...rows.map(row => ({ key: row.slug, category: row.release ? pluginPresentation(row.release).category : "Other", installed: row.difference !== "added", title: row.name, secondary: rowState(row), warn: Boolean(row.problem),
74
+ checked: row.included, disabled: row.difference === "required", onChange: () => toggle(row),
75
+ trailing: row.included && row.difference !== "required" && hasSettings(row.release) ? <Button size="sm" variant="ghost" isIconOnly aria-label={`${row.name} settings for this Work`} onPress={() => onViewChange(row.slug)}>{row.problem ? <AlertTriangle size={14} className="text-warning" aria-hidden="true" /> : <Settings2 size={14} aria-hidden="true" />}</Button> : undefined })),
76
+ ...catalog.filter(release => !rows.some(row => row.slug === release.slug)).map(release => ({ key: release.slug, category: pluginPresentation(release).category, installed: false, title: pluginPresentation(release).name || pluginDisplayName(release.slug), secondary: release.description, checked: false, onChange: () => add(release.slug) })),
77
+ ];
78
+ return <TrayPopover label="Plugins" value={`${included.length} plugins${changes ? ` · ${changes} changed` : ""}`} icon={<Puzzle size={16} />} disabled={disabled} attention={included.some(row => row.problem)} open={open} wide={!detail} onOpenChange={value => { onOpenChange(value); if (value) onViewChange(""); }}>
79
+ {detail ? <>
80
+ {/* The settings view replaces the button that opened it; focus moves here so it never falls out of the popover and closes it. */}
81
+ <div className="wst-back-row"><Button size="sm" variant="ghost" isIconOnly autoFocus aria-label="Back to plugins" onPress={() => onViewChange("")}><ArrowLeft size={15} /></Button><span className="wst-heading">{detail.name}</span></div>
82
+ {hasSettings(detail.release) ? <div className="wst-params">
83
+ <ParameterFields declarations={detail.release!.parameters} drafts={draft.plugins[detail.slug]?.values ?? {}} errors={errors[detail.slug]} redacted={detail.installed?.redactedParameters} inherited={draftsFrom(detail.installed?.configuration)}
84
+ onChange={(name, value) => update(current => ({ ...current, plugins: { ...current.plugins, [detail.slug]: { include: current.plugins[detail.slug]?.include, values: { ...(current.plugins[detail.slug]?.values ?? {}), [name]: value } } } }))} />
85
+ {detail.edited.length > 0 && <Button size="sm" variant="ghost" onPress={() => update(current => ({ ...current, plugins: { ...current.plugins, [detail.slug]: { include: current.plugins[detail.slug]?.include, values: {} } } }))}>Use my defaults</Button>}
86
+ </div> : <p className="wpb-empty">This plugin has no settings.</p>}
87
+ <ScopeNote />
88
+ </> : <CatalogPicker noun="plugins" entries={entries} />}
89
+ </TrayPopover>;
90
+ }
91
+
92
+ // Connectors mirror Plugins: defaults on, removable or addable for this Work.
93
+ // A Provider that isn't connected is shown with a way to connect; it never
94
+ // blocks the start. A Connector a Work on the chosen Managed Computer leaves
95
+ // out is shown as unavailable, a required one included.
96
+ export function ConnectorsMenu({ rows, update, disabled, open, onOpenChange, returnTo }: { rows: ConnectorRow[]; update: Update; disabled: boolean; open: boolean; onOpenChange: (open: boolean) => void; returnTo: string }) {
97
+ const connect = useConnectProvider();
98
+ const included = rows.filter(row => row.included);
99
+ const toggle = (row: ConnectorRow) => update(current => {
100
+ const connectors = { ...current.connectors };
101
+ if (row.difference === "removed" || (row.difference === "added" && row.included)) delete connectors[row.name];
102
+ else connectors[row.name] = !row.included;
103
+ return { ...current, connectors };
104
+ });
105
+ const entries: Entry[] = rows.map(row => {
106
+ const provider = row.connector?.provider ?? "";
107
+ const needs = Boolean(row.included && provider && row.connector?.providerConnection !== "connected");
108
+ const category = row.connector?.category ? pluginDisplayName(row.connector.category) : "Other";
109
+ return { key: row.name, category, installed: row.difference !== "added", title: serviceLabel(row.name), checked: row.included, disabled: row.difference === "required" || row.unavailable, warn: needs, onChange: () => toggle(row),
110
+ secondary: row.unavailable ? "Not available on this Computer" : row.difference === "required" ? "Always included" : row.difference === "added" && row.included ? "Added for this Work" : needs ? `${providerName(provider)} isn’t connected — its tools won’t work` : provider ? `Signs in with ${providerName(provider)}` : row.connector?.description ?? "",
111
+ trailing: needs ? <Button size="sm" variant="secondary" isPending={connect.isPending} onPress={() => connect.mutate({ provider, returnTo })}>Connect</Button> : undefined };
112
+ });
113
+ return <TrayPopover label="Connectors" value={`${included.length} ${included.length === 1 ? "connector" : "connectors"}`} icon={<Plug size={16} />} disabled={disabled} wide attention={included.some(row => row.connector?.provider && row.connector.providerConnection !== "connected")} open={open} onOpenChange={onOpenChange}>
114
+ <CatalogPicker noun="connectors" entries={entries} />
115
+ </TrayPopover>;
116
+ }
@@ -0,0 +1,173 @@
1
+ import { useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { Button } from "@heroui/react";
4
+ import { AlertTriangle, ArrowUp, Paperclip, X } from "lucide-react";
5
+ import { useLocation } from "react-router-dom";
6
+ import type { WorkStartInput } from "../../lib/start-work-request";
7
+ import { useConnectors, useInstalledPlugins, usePluginCatalog, usePluginReleases } from "../../lib/marketplace-catalog";
8
+ import { useUsableComputers } from "../../lib/computer-service";
9
+ import { startModels, startModelSelection } from "../../lib/work-model";
10
+ import { defaultPermissionMode } from "../../lib/permission-modes";
11
+ import { useWorkStartDraft } from "../../lib/work-start-draft";
12
+ import type { WorkStartSource } from "../../lib/work-start-source";
13
+ import { connectorChoices, connectorLoadout, pluginChoices, pluginLoadout } from "../../lib/work-start-loadout";
14
+ import { startWorkErrorMessage } from "./start-work-launch-state";
15
+ import { PromptBar } from "../composer/prompt-bar";
16
+ import { PromptAddMenu, PromptModelControl } from "../composer/prompt-bar-controls";
17
+ import { SkillHighlight } from "../composer/skill-completion";
18
+ import { useSlashCompletion, type SlashCandidate } from "../composer/use-slash-completion";
19
+ import { ConnectorsMenu, PluginsMenu } from "./work-start-catalog";
20
+ import { AgentPicker, ComputerPicker, HarnessPicker, RepositoryPicker } from "./work-start-tray";
21
+ import "./work-start-tray.css";
22
+
23
+ type Menu = "Computer" | "Permissions" | "Repository" | "Agent" | "Plugins" | "Connectors";
24
+
25
+ // `unavailable` shows the form disabled with the reason, without anyone's
26
+ // personal loadout: a Work runs with its owner's own choices.
27
+ export function WorkStartForm({ source, disabled: blocked = false, unavailable, onStart }: { source: WorkStartSource; disabled?: boolean; unavailable?: ReactNode; onStart: (input: WorkStartInput) => Promise<void> }) {
28
+ const disabled = blocked || Boolean(unavailable);
29
+ const id = useId();
30
+ const input = useRef<HTMLTextAreaElement>(null);
31
+ const fileInput = useRef<HTMLInputElement>(null);
32
+ const { pathname } = useLocation();
33
+ const { draft, update, current } = useWorkStartDraft(source.draft);
34
+ const options = useQuery({ queryKey: ["work-start-options", ...source.key], queryFn: ({ signal }) => source.loadOptions(signal), retry: false, enabled: !unavailable });
35
+ // Repository discovery waits on the Provider, so it loads apart from the other options.
36
+ const repositories = useQuery({ queryKey: ["work-start-repositories", ...source.key], queryFn: ({ signal }) => source.loadRepositories(signal), retry: false, enabled: !unavailable });
37
+ const catalog = usePluginCatalog();
38
+ const installed = useInstalledPlugins();
39
+ const connectors = useConnectors();
40
+ // Only a source that chooses a Computer offers the choice; without one the platform's default runs the Work.
41
+ const computers = useUsableComputers(Boolean(source.chooseComputer) && !unavailable);
42
+ const computerList = source.chooseComputer ? computers.data : undefined;
43
+ // A remembered choice is never dropped silently: the start waits for the
44
+ // list that confirms it, and one the list no longer has must be chosen again.
45
+ const computerMissing = Boolean(draft.computer && computerList && !computerList.some(item => item.id === draft.computer));
46
+ const computer = computerMissing ? undefined : computerList?.find(item => item.id === draft.computer) ?? computerList?.[0];
47
+ const offline = computer && !computer.reachable ? computer : undefined;
48
+ const computerUnconfirmed = Boolean(source.chooseComputer && draft.computer && !computerList);
49
+ const managed = Boolean(computer?.managed);
50
+ // Only a Managed Computer's Harness takes a permission mode and a sandbox.
51
+ // A machine without Claude Code's sandbox is never asked for it.
52
+ const chosenHarness = managed ? draft.harness ?? { permissionMode: defaultPermissionMode, sandbox: false } : undefined;
53
+ const harness = chosenHarness && { ...chosenHarness, sandbox: chosenHarness.sandbox && Boolean(computer?.sandbox) };
54
+ const [menu, setMenu] = useState<Menu | null>(null);
55
+ // A Plugin's slug while its settings for this Work are open.
56
+ const [pluginView, setPluginView] = useState("");
57
+ const [modelOpen, setModelOpen] = useState(false);
58
+ const [error, setError] = useState("");
59
+ const [invalid, setInvalid] = useState(false);
60
+ const [submitted, setSubmitted] = useState(false);
61
+ const [uploading, setUploading] = useState("");
62
+ const models = startModels(options.data?.models ?? [], managed);
63
+ // The first model offered is the default, also when a saved draft names
64
+ // one that is no longer offered.
65
+ const model = models.find(item => !item.machineLogin && item.configurationId === draft.model?.configurationId) ?? models[0];
66
+ const thinking = draft.thinkingLevel ?? model?.defaultThinkingLevel ?? "";
67
+ // Plugins added for this Work are read in full for their Skills and Agents.
68
+ const added = unavailable ? [] : Object.entries(draft.plugins).filter(([slug, choice]) => choice.include && !(options.data?.plugins ?? []).some(plugin => plugin.name === slug)).map(([slug]) => slug);
69
+ const details = usePluginReleases(added);
70
+ const rows = unavailable ? [] : pluginLoadout(options.data?.plugins ?? [], catalog.data ?? [], installed.data, draft, details);
71
+ const included = rows.filter(row => row.included);
72
+ const connectorRows = unavailable ? [] : connectorLoadout(options.data?.connectors ?? [], connectors.data ?? [], draft, managed);
73
+ const agents = included.flatMap(row => row.agents);
74
+ const candidates: SlashCandidate[] = included.flatMap(row => row.skills.map(skill => ({ ...skill, group: row.name })));
75
+ const slash = useSlashCompletion(draft.instruction, instruction => update({ instruction }), candidates, `${id}-skills`, true);
76
+ const choices = pluginChoices(rows, draft);
77
+ const problems = included.filter(row => row.problem);
78
+ const agentMissing = Boolean(draft.agent) && !agents.some(agent => agent.name === draft.agent);
79
+ const controlsDisabled = disabled || draft.submitting;
80
+ const openMenu = (name: Menu) => (open: boolean) => setMenu(open ? name : null);
81
+
82
+ useEffect(() => {
83
+ const element = input.current;
84
+ if (element) { element.style.height = "auto"; element.style.height = `${Math.min(element.scrollHeight, 220)}px`; }
85
+ }, [draft.instruction]);
86
+
87
+ async function submit(event?: FormEvent<HTMLFormElement>) {
88
+ event?.preventDefault();
89
+ if (current().submitting || disabled) return;
90
+ setInvalid(false); setError(""); setSubmitted(true);
91
+ if (!draft.instruction.trim()) { setInvalid(true); setError("Enter an initial instruction."); input.current?.focus(); return; }
92
+ // Load failures are reported with a retry; there is nothing to correct here.
93
+ if (options.isError || catalog.isError || installed.isError) return;
94
+ if (!model || (thinking && !model.thinkingLevels.includes(thinking))) { setError("Choose an available model and thinking level before starting."); return; }
95
+ // Blocking problems are reported before submission; missing authorization is not one.
96
+ if (problems.length || Object.keys(choices.errors).length || agentMissing || offline) return;
97
+ if (computerMissing) { setError("Choose a Computer before starting."); return; }
98
+ // A Computer list that failed to load is reported with a retry.
99
+ if (computerUnconfirmed) { if (!computers.isError) setError("Computers are still loading. Try again in a moment."); return; }
100
+ // Chosen repositories are checked once their discovery answers; the start waits for it.
101
+ const reachable = new Set((repositories.data?.repositories ?? []).map(repository => repository.id));
102
+ if (draft.repositories.length && repositories.isPending) { setError("Repositories are still loading. Try again in a moment."); return; }
103
+ if (draft.repositories.some(repository => !reachable.has(repository))) { setError("A selected repository is no longer reachable. Update your selection before starting."); return; }
104
+ update({ submitting: true }); setMenu(null); setModelOpen(false);
105
+ try {
106
+ for (const attachment of draft.files) {
107
+ if (attachment.uploaded) continue;
108
+ setUploading(attachment.file.name);
109
+ await source.uploadAttachment?.(attachment.file);
110
+ update(value => ({ ...value, files: value.files.map(item => item.id === attachment.id ? { ...item, uploaded: true } : item) }));
111
+ }
112
+ setUploading("");
113
+ const instructions = [draft.instruction.trim()];
114
+ if (draft.files.length) instructions.push(`Use the attached files: ${draft.files.map(item => item.file.name).join(", ")}.`);
115
+ const connectorsChanged = connectorChoices(connectorRows);
116
+ await onStart({ initialMessage: instructions.join("\n\n"), model: startModelSelection(model), thinkingLevel: thinking,
117
+ repositorySelections: draft.repositories.map(repositoryId => ({ repositoryId, baseBranch: "" })),
118
+ ...(computer && { computerId: computer.id }),
119
+ ...(harness && { harness }),
120
+ ...(choices.plugins.length || connectorsChanged.length || draft.agent ? { creationCapabilities: { plugins: choices.plugins, connectors: connectorsChanged, ...(draft.agent ? { startupAgent: draft.agent } : {}) } } : {}),
121
+ });
122
+ update({ files: [] });
123
+ } catch (cause) { setError(startWorkErrorMessage(cause, computer)); }
124
+ finally { setUploading(""); update({ submitting: false }); }
125
+ }
126
+
127
+ return <form className="wpb-form" noValidate onSubmit={event => void submit(event)}>
128
+ {unavailable}
129
+ {/* The slash menu floats under the composer, over the tray, like the tray's own cards. */}
130
+ <div className="relative">
131
+ {slash.menu && <div className="sk-menu-float sk-menu-under">{slash.menu}</div>}
132
+ <PromptBar
133
+ input={{ ref: input, "aria-label": "Initial instruction", "aria-invalid": invalid, "aria-describedby": invalid ? `${id}-error` : undefined, value: draft.instruction, placeholder: "What would you like to do? Type / for Skills", disabled: controlsDisabled, ...slash.inputProps,
134
+ onChange: event => update({ instruction: event.target.value }),
135
+ onKeyDown: event => { if (slash.onKeyDown(event)) return; if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing && event.keyCode !== 229) { event.preventDefault(); void submit(); } } }}
136
+ context={<>
137
+ <SkillHighlight text={draft.instruction} candidates={candidates} />
138
+ {draft.files.length > 0 && <div className="wpb-context" aria-label="Selected context">
139
+ {draft.files.map(attachment => <Button key={attachment.id} size="sm" variant="tertiary" isDisabled={controlsDisabled} aria-label={`Remove attachment ${attachment.file.name}`} onPress={() => update({ files: draft.files.filter(item => item.id !== attachment.id) })}><Paperclip size={13} /><span className="wpb-filename">{attachment.file.name}</span><X size={12} /></Button>)}
140
+ </div>}
141
+ </>}
142
+ leading={<PromptAddMenu disabled={controlsDisabled} onAttach={source.uploadAttachment ? () => fileInput.current?.click() : undefined}
143
+ onCommands={() => { update(value => ({ ...value, instruction: !value.instruction || /\s$/.test(value.instruction) ? `${value.instruction}/` : `${value.instruction} /` })); setTimeout(() => input.current?.focus()); }} />}
144
+ >
145
+ <PromptModelControl models={models} model={model} thinking={thinking} disabled={controlsDisabled} loading={options.isPending && !unavailable} open={modelOpen} onOpenChange={setModelOpen} onChange={(next, thinkingLevel) => update({ model: startModelSelection(next), thinkingLevel })} />
146
+ <Button type="submit" size="sm" isIconOnly className="wpb-send" aria-label="Start work" isPending={draft.submitting} isDisabled={disabled || options.isPending || installed.isLoading || !model}><ArrowUp size={19} /></Button>
147
+ </PromptBar>
148
+ </div>
149
+ <input ref={fileInput} type="file" multiple hidden aria-label="Choose attachments" disabled={controlsDisabled} onChange={event => { const files = Array.from(event.target.files ?? []).map(file => ({ id: crypto.randomUUID(), file, uploaded: false })); update(value => ({ ...value, files: [...value.files, ...files] })); event.target.value = ""; }} />
150
+ <div className="wpb-tray" role="group" aria-label="Work startup configuration">
151
+ {/* Another kind of Computer offers other models, so its thinking effort starts over; a Cloud drops the Harness choices. */}
152
+ <ComputerPicker computers={computerList} selected={computer} disabled={controlsDisabled} open={menu === "Computer"} onOpenChange={openMenu("Computer")}
153
+ onChange={id => update(value => { const toManaged = Boolean(computerList?.find(item => item.id === id)?.managed); return { ...value, computer: id, ...(toManaged !== managed && { thinkingLevel: undefined }), ...(!toManaged && { harness: undefined }) }; })} />
154
+ {harness && <HarnessPicker value={harness} sandboxAvailable={Boolean(computer?.sandbox)} disabled={controlsDisabled} open={menu === "Permissions"} onOpenChange={openMenu("Permissions")} onChange={next => update({ harness: next })} />}
155
+ <RepositoryPicker repositories={repositories.data?.repositories ?? []} recent={repositories.data?.recentRepositoryIds ?? []} unavailable={repositories.isError || Boolean(repositories.data?.repositoriesUnavailable)} loading={repositories.isPending}
156
+ selected={draft.repositories} disabled={controlsDisabled} open={menu === "Repository"} onOpenChange={openMenu("Repository")} onChange={selected => update({ repositories: selected })} onRetry={() => void repositories.refetch()} returnTo={pathname} />
157
+ <AgentPicker agents={agents} value={draft.agent} disabled={controlsDisabled} open={menu === "Agent"} onOpenChange={openMenu("Agent")} onChange={agent => update({ agent })} />
158
+ <PluginsMenu rows={rows} catalog={catalog.data ?? []} draft={draft} update={update} disabled={controlsDisabled} open={menu === "Plugins"} onOpenChange={openMenu("Plugins")} view={pluginView} onViewChange={setPluginView} errors={submitted ? choices.errors : {}} />
159
+ <ConnectorsMenu rows={connectorRows} update={update} disabled={controlsDisabled} open={menu === "Connectors"} onOpenChange={openMenu("Connectors")} returnTo={pathname} />
160
+ </div>
161
+ {submitted && (problems.length > 0 || Object.keys(choices.errors).length > 0 || agentMissing || offline) && <div className="wst-feedback" role="alert">
162
+ {problems.map(row => <p key={row.slug} className="wpb-feedback-error"><AlertTriangle size={14} aria-hidden="true" /> {row.name}: {row.problem.toLowerCase()}. <Button size="sm" variant="ghost" onPress={() => { setPluginView(row.slug); setMenu("Plugins"); }}>Fix</Button></p>)}
163
+ {Object.keys(choices.errors).filter(slug => !problems.some(row => row.slug === slug)).map(slug => <p key={slug} className="wpb-feedback-error"><AlertTriangle size={14} aria-hidden="true" /> {rows.find(row => row.slug === slug)?.name}: check its settings for this Work. <Button size="sm" variant="ghost" onPress={() => { setPluginView(slug); setMenu("Plugins"); }}>Fix</Button></p>)}
164
+ {agentMissing && <p className="wpb-feedback-error"><AlertTriangle size={14} aria-hidden="true" /> The selected Startup Agent is no longer available. Choose another one.</p>}
165
+ {offline && <p className="wpb-feedback-error"><AlertTriangle size={14} aria-hidden="true" /> {offline.name} is offline. Choose another Computer.</p>}
166
+ </div>}
167
+ {draft.submitting && <p className="sr-only" role="status">{uploading ? `Uploading ${uploading}…` : "Submitting startup request…"}</p>}
168
+ {computerMissing && <p className="wpb-feedback" role="alert">The Computer you chose is no longer available. <Button size="sm" variant="ghost" onPress={() => setMenu("Computer")}>Choose a Computer</Button></p>}
169
+ {source.chooseComputer && computers.isError && !computerList && <p className="wpb-feedback" role="alert">Computers could not be loaded. <Button size="sm" variant="ghost" onPress={() => void computers.refetch()}>Retry</Button></p>}
170
+ {(options.isError || catalog.isError || installed.isError) && <p className="wpb-feedback" role="alert">Startup configuration could not be loaded. <Button size="sm" variant="ghost" onPress={() => { void options.refetch(); void catalog.refetch(); void installed.refetch(); }}>Retry</Button></p>}
171
+ {error && <p id={`${id}-error`} className="wpb-feedback wpb-feedback-error" role="alert">{error}</p>}
172
+ </form>;
173
+ }
@@ -0,0 +1,40 @@
1
+ /* Start Work tray cards: per-Work choices as check rows, and Plugins and
2
+ Connectors browsed by catalog category beside a rail. */
3
+ .wst-rows { display: grid; gap: 1px; align-content: start; }
4
+ .wst-scroll { max-height: 320px; overflow-y: auto; margin-top: 4px; }
5
+ .wst-group-title { padding: 8px 10px 2px; font-size: 11px; font-weight: 500; color: var(--muted); }
6
+ .wst-check-row { display: flex; align-items: center; gap: 6px; min-height: 44px; padding: 2px 4px 2px 10px; border-radius: 10px; }
7
+ .wst-check-row:hover { background: color-mix(in oklch, var(--foreground) 4%, transparent); }
8
+ .wst-check { flex: 1; min-width: 0; }
9
+ .wst-check .checkbox__content { flex-direction: row; align-items: center; justify-content: flex-start; gap: 12px; width: 100%; text-align: left; }
10
+ .wst-check .checkbox__control { box-shadow: inset 0 0 0 1px var(--border); }
11
+ .wst-check-copy { display: grid; gap: 1px; min-width: 0; }
12
+ .wst-check-title { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
13
+ .wst-check-copy > :not(.wst-check-title) { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
14
+ .wst-check-row[data-struck] .wst-check-title { color: var(--muted); text-decoration: line-through; }
15
+ .wst-catalog { display: grid; grid-template-columns: 168px minmax(0, 1fr); gap: 6px; height: 272px; margin-top: 4px; }
16
+ .wst-catalog[data-searching] { grid-template-columns: minmax(0, 1fr); }
17
+ .wst-rail { overflow-y: auto; padding-right: 6px; border-right: 1px solid var(--separator); }
18
+ .wst-rail-item { display: flex; align-items: center; gap: 8px; min-height: 34px; }
19
+ .wst-rail-item[aria-selected="true"] { background: color-mix(in oklch, var(--foreground) 6%, transparent); }
20
+ .wst-rail-item[aria-selected="true"] .wst-rail-name { font-weight: 500; }
21
+ .wst-rail-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
22
+ .wst-count { font-size: 11px; font-variant-numeric: tabular-nums; }
23
+ .wst-pane { min-width: 0; overflow-y: auto; }
24
+ .wst-back-row { display: flex; align-items: center; gap: 6px; padding-bottom: 6px; margin-bottom: 4px; border-bottom: 1px solid var(--separator); }
25
+ .wst-heading { font-size: 13px; font-weight: 500; }
26
+ .wst-params { display: grid; gap: 12px; padding: 4px 6px 8px; }
27
+ .wst-picker-empty { display: grid; gap: 10px; justify-items: start; padding: 8px; font-size: 13px; color: var(--muted); }
28
+ .wst-switch-row { padding: 8px 10px 4px; margin-top: 4px; border-top: 1px solid var(--separator); font-size: 13px; }
29
+ .wst-switch-row .switch, .wst-switch-row .switch__content { width: 100%; }
30
+ .wst-switch-row .switch__content { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
31
+ .wst-switch-row .wst-switch-note { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; }
32
+ .wst-offline { margin-left: auto; flex-shrink: 0; color: var(--muted); font-size: 12px; }
33
+ .wst-feedback { display: grid; gap: 4px; margin: 12px 20px 0; font-size: 13px; }
34
+ .wst-feedback p { display: flex; align-items: center; gap: 6px; }
35
+
36
+ .wst-unavailable { margin: 0 0 16px; }
37
+ @media (max-width: 740px) {
38
+ .wpb-popover-wide { width: calc(100vw - 24px); }
39
+ .wst-catalog { grid-template-columns: 128px minmax(0, 1fr); }
40
+ }