@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.
- package/README.md +15 -2
- package/assets/brand-icons/LICENSE.md +30 -0
- package/assets/brand-icons/README.md +5 -0
- package/assets/brand-icons/figma.svg +1 -0
- package/assets/brand-icons/github.svg +1 -0
- package/assets/brand-icons/linear.svg +1 -0
- package/assets/brand-icons/notion.svg +1 -0
- package/assets/brand-icons/sentry.svg +1 -0
- package/assets/brand-icons/slack.svg +1 -0
- package/assets/fonts/Inter-OFL.txt +93 -0
- package/assets/fonts/inter-latin.woff2 +0 -0
- package/components/agent/agent-icon.css +14 -0
- package/components/agent/agent-icon.tsx +14 -0
- package/components/analytics/chart-geometry.ts +95 -0
- package/components/analytics/dashboard-charts.tsx +136 -0
- package/components/analytics/model-usage-chart.tsx +84 -0
- package/components/analytics/token-breakdown.css +13 -0
- package/components/analytics/token-breakdown.tsx +23 -0
- package/components/analytics/token-report.tsx +57 -0
- package/components/analytics/work-usage/turn-timeline.tsx +98 -0
- package/components/analytics/work-usage/turns.ts +170 -0
- package/components/analytics/work-usage/work-status.ts +36 -0
- package/components/analytics/work-usage/work-usage-reader.tsx +122 -0
- package/components/analytics/work-usage/work-usage.css +48 -0
- package/components/auth/auth-gate.tsx +44 -0
- package/components/chat/message-input.tsx +105 -0
- package/components/composer/permission-mode-list.tsx +15 -0
- package/components/composer/prompt-bar-controls.tsx +77 -0
- package/components/composer/prompt-bar.tsx +45 -0
- package/components/composer/skill-completion.css +27 -0
- package/components/composer/skill-completion.tsx +49 -0
- package/components/composer/use-slash-completion.tsx +69 -0
- package/components/composer/work-start.css +133 -0
- package/components/feedback/toast-region.css +2 -0
- package/components/feedback/toast-region.tsx +15 -0
- package/components/files/file-facts.tsx +16 -0
- package/components/files/file-section.tsx +181 -0
- package/components/markdown.tsx +144 -0
- package/components/marketplace/connector-pages.tsx +166 -0
- package/components/marketplace/marketplace-icons.tsx +66 -0
- package/components/marketplace/marketplace-shared.tsx +43 -0
- package/components/marketplace/marketplace-shell.tsx +32 -0
- package/components/marketplace/marketplace.css +97 -0
- package/components/marketplace/parameter-fields.tsx +46 -0
- package/components/marketplace/plugin-list.tsx +84 -0
- package/components/marketplace/plugin-page.tsx +133 -0
- package/components/marketplace/provider-pages.tsx +120 -0
- package/components/preview/file-format.ts +75 -0
- package/components/preview/file-preview.tsx +153 -0
- package/components/preview/preview-verdict.ts +44 -0
- package/components/preview/preview.css +19 -0
- package/components/preview/text-preview.tsx +174 -0
- package/components/profile/provider-callback.tsx +43 -0
- package/components/profile/work-agent-appearance.tsx +109 -0
- package/components/profile/work-agent-soul.tsx +76 -0
- package/components/rail/rail-section.tsx +20 -0
- package/components/rail/rail.css +19 -0
- package/components/rail/use-expand-on-change.ts +18 -0
- package/components/session/startup-progress.css +77 -0
- package/components/session/startup-progress.tsx +103 -0
- package/components/session/work-permission-control.tsx +45 -0
- package/components/session/work-thinking-control.tsx +52 -0
- package/components/shell/account-menu.tsx +134 -0
- package/components/shell/app-shell.tsx +148 -0
- package/components/shell/core-reading-views.tsx +26 -0
- package/components/shell/core-reading.tsx +17 -0
- package/components/shell/document-title.tsx +11 -0
- package/components/shell/entry-shell.ts +39 -0
- package/components/shell/file-reading.tsx +22 -0
- package/components/shell/page-layout.tsx +7 -0
- package/components/shell/reading-context.ts +41 -0
- package/components/shell/reading-panel.css +67 -0
- package/components/shell/reading-panel.tsx +74 -0
- package/components/shell/sidebar-parts.tsx +38 -0
- package/components/work/context-rail.css +61 -0
- package/components/work/conversation-unavailable.css +6 -0
- package/components/work/conversation-unavailable.tsx +11 -0
- package/components/work/subagent-item.tsx +42 -0
- package/components/work/subagents-section.css +31 -0
- package/components/work/subagents-section.tsx +40 -0
- package/components/work/token-usage.tsx +28 -0
- package/components/work/tool-message-detail.tsx +39 -0
- package/components/work/tool-output-preview.tsx +52 -0
- package/components/work/tool-preview-reading.tsx +27 -0
- package/components/work/tool-reading-content.tsx +31 -0
- package/components/work/work-context-rail.tsx +23 -0
- package/components/work/work-conversation.tsx +207 -0
- package/components/work/work-history.tsx +115 -0
- package/components/work/work-interaction.css +38 -0
- package/components/work/work-interaction.tsx +105 -0
- package/components/work/work-message-flow.css +86 -0
- package/components/work/work-message-flow.tsx +352 -0
- package/components/work/work-progress-section.tsx +20 -0
- package/components/work/work-prompt-bar.tsx +82 -0
- package/components/work/work-question-record.tsx +72 -0
- package/components/work/work-snapshot-state.tsx +24 -0
- package/components/work/work-status-control.tsx +54 -0
- package/components/work-start/start-work-launch-state.ts +33 -0
- package/components/work-start/work-start-catalog.tsx +116 -0
- package/components/work-start/work-start-form.tsx +173 -0
- package/components/work-start/work-start-tray.css +40 -0
- package/components/work-start/work-start-tray.tsx +128 -0
- package/hooks/use-debounced.ts +11 -0
- package/hooks/use-work-conversation.ts +125 -0
- package/hooks/use-work-lifecycle-controls.ts +40 -0
- package/hooks/use-work-stream.ts +91 -0
- package/lib/account-service.ts +77 -0
- package/lib/agent-appearance-service.ts +59 -0
- package/lib/agent-icon.ts +26 -0
- package/lib/agent-soul-service.ts +48 -0
- package/lib/agent-work-service.ts +23 -0
- package/lib/client.ts +20 -0
- package/lib/computer-release.ts +30 -0
- package/lib/computer-service.ts +241 -0
- package/lib/dashboard-report.ts +38 -0
- package/lib/dashboard.ts +21 -0
- package/lib/marketplace-catalog.ts +138 -0
- package/lib/marketplace-connections.ts +98 -0
- package/lib/marketplace-plugins.ts +53 -0
- package/lib/marketplace-routes.ts +12 -0
- package/lib/model-vendors.ts +13 -0
- package/lib/name-taken-error.ts +7 -0
- package/lib/notifications.ts +37 -0
- package/lib/permission-modes.ts +25 -0
- package/lib/personal-usage-service.ts +19 -0
- package/lib/personal-work-service.ts +159 -0
- package/lib/plugin-parameters.ts +64 -0
- package/lib/preview-verdict.ts +26 -0
- package/lib/relative-time.ts +11 -0
- package/lib/session.ts +108 -0
- package/lib/share-link.ts +4 -0
- package/lib/stale-build-reload.ts +41 -0
- package/lib/start-work-request.ts +34 -0
- package/lib/startup-progress.ts +9 -0
- package/lib/subagents.ts +12 -0
- package/lib/thinking-levels.ts +2 -0
- package/lib/token-analytics.ts +168 -0
- package/lib/token-usage.ts +64 -0
- package/lib/trace-view-model.ts +292 -0
- package/lib/transport.ts +29 -0
- package/lib/use-now.ts +11 -0
- package/lib/use-work-trace.ts +175 -0
- package/lib/user-display.ts +49 -0
- package/lib/work-conversation.ts +118 -0
- package/lib/work-file-service.ts +118 -0
- package/lib/work-model.ts +38 -0
- package/lib/work-notices.ts +23 -0
- package/lib/work-progress.ts +41 -0
- package/lib/work-projection.ts +122 -0
- package/lib/work-questions.ts +18 -0
- package/lib/work-start-draft.ts +61 -0
- package/lib/work-start-loadout.ts +100 -0
- package/lib/work-start-source.ts +16 -0
- package/lib/work-state-presentation.ts +80 -0
- package/lib/work-stream-source.ts +32 -0
- package/package.json +81 -5
- package/pages/computers/computers-page.tsx +56 -0
- package/pages/computers/connect-computer-page.tsx +92 -0
- package/pages/computers/install-commands.tsx +42 -0
- package/pages/computers/remove-computer-dialog.tsx +33 -0
- package/pages/computers/rename-computer-dialog.tsx +37 -0
- package/pages/computers/room-rules-dialog.tsx +55 -0
- package/pages/login-page.css +33 -0
- package/pages/login-page.tsx +88 -0
- package/pages/login-threads.tsx +51 -0
- package/pages/marketplace-page.tsx +59 -0
- package/pages/profile-page.tsx +68 -0
- package/pages/workbench/library-page.tsx +88 -0
- package/pages/workbench/new-work-page.tsx +24 -0
- package/pages/workbench/person.tsx +13 -0
- package/pages/workbench/personal-work-context.tsx +93 -0
- package/pages/workbench/personal-work-page.tsx +100 -0
- package/pages/workbench/personal-works-page.tsx +36 -0
- package/pages/workbench/share-dialog.tsx +57 -0
- package/pages/workbench/title-dialog.tsx +35 -0
- package/pages/workbench/usage-page.tsx +52 -0
- package/public/assets/brand/talgent-lettering.png +0 -0
- package/public/assets/brand/talgent-logo-page-bg.png +0 -0
- package/public/assets/brand/talgent-logo-white-bg.png +0 -0
- package/public/assets/brand/talgent-mark-page-bg.png +0 -0
- package/public/assets/brand/talgent-mark-white-bg.png +0 -0
- package/public/assets/brand/talgent-mark.png +0 -0
- package/public/assets/product-icons/LICENSE +21 -0
- package/public/assets/product-icons/README.md +13 -0
- package/public/assets/product-icons/anthropic.svg +1 -0
- package/public/assets/product-icons/deepseek.svg +1 -0
- package/public/assets/product-icons/glm.svg +1 -0
- package/public/assets/product-icons/kimi.svg +1 -0
- package/public/assets/product-icons/linear.svg +1 -0
- package/public/assets/product-icons/minimax.svg +1 -0
- package/public/assets/product-icons/notion.svg +1 -0
- package/public/assets/product-icons/openai.svg +1 -0
- package/public/assets/product-icons/qwen.svg +1 -0
- package/styles/base.css +95 -0
- package/styles/fonts.css +14 -0
- package/styles/markdown.css +79 -0
- package/styles/uber-theme.css +96 -0
- package/styles/work-composer.css +222 -0
- package/styles/work-stream.css +343 -0
- package/styles/workbench.css +144 -0
- package/styles/workspace.css +260 -0
- package/test-support/backend.ts +67 -0
- package/test-support/plugin-summary.ts +8 -0
- package/test-support/setup.ts +59 -0
- package/vite-preset.ts +85 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Alert } from "@heroui/react";
|
|
2
|
+
import type { ToolOutputPreview as Preview } from "@talgent/workbench-contracts/dispatcher/public/v1/unified_message_pb";
|
|
3
|
+
import { DiffView, SourcePreview, TextPreview } from "../preview/text-preview";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What one Read, Write or Edit call actually read, wrote or changed, from the
|
|
7
|
+
* call's own result. The path only picks the rendering; the file itself is
|
|
8
|
+
* never read, and nothing is rebuilt from the input.
|
|
9
|
+
*/
|
|
10
|
+
export function ToolOutputPreview({ preview, toolbar }: { preview: Preview; toolbar: HTMLElement | null }) {
|
|
11
|
+
const notes: string[] = [];
|
|
12
|
+
if (preview.truncated) notes.push(`Showing the first ${shownLength(preview).toLocaleString()} of ${preview.totalLength.toLocaleString()} characters.`);
|
|
13
|
+
const empty = preview.toolName !== "Edit" && preview.content === "";
|
|
14
|
+
if (preview.toolName === "Read") {
|
|
15
|
+
const last = preview.startLine + preview.numLines - 1;
|
|
16
|
+
const whole = preview.startLine <= 1 && last >= preview.totalLines && !preview.truncatedByTokenCap && !preview.truncated;
|
|
17
|
+
// A partial read names its range as a file preview names its truncation.
|
|
18
|
+
if (!whole && preview.numLines > 0) notes.unshift(`Showing lines ${preview.startLine}–${last} of ${preview.totalLines}.`);
|
|
19
|
+
if (preview.truncatedByTokenCap) notes.push("The read stopped at the token limit.");
|
|
20
|
+
return <section aria-label="Read content">
|
|
21
|
+
<Notes notes={notes} />
|
|
22
|
+
{empty ? <p>Empty file</p> : whole
|
|
23
|
+
? <TextPreview path={preview.filePath} content={preview.content} toolbar={toolbar} />
|
|
24
|
+
: <SourcePreview path={preview.filePath} content={preview.content} startLine={Math.max(1, preview.startLine)} />}
|
|
25
|
+
</section>;
|
|
26
|
+
}
|
|
27
|
+
if (preview.toolName === "Write") {
|
|
28
|
+
return <section aria-label="Written content">
|
|
29
|
+
<Notes notes={notes} />
|
|
30
|
+
{/* A cut body is never rendered as a whole document. */}
|
|
31
|
+
{empty ? <p>Empty file</p> : preview.truncated
|
|
32
|
+
? <SourcePreview path={preview.filePath} content={preview.content} />
|
|
33
|
+
: <TextPreview path={preview.filePath} content={preview.content} toolbar={toolbar} />}
|
|
34
|
+
</section>;
|
|
35
|
+
}
|
|
36
|
+
if (preview.toolName !== "Edit") return null;
|
|
37
|
+
return <section aria-label="Edited lines">
|
|
38
|
+
<Notes notes={notes} />
|
|
39
|
+
<DiffView content={preview.hunks.map(hunk => [`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`, ...hunk.lines].join("\n")).join("\n")} />
|
|
40
|
+
</section>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Code points shown, counted as the limit counts them: a patch line and its break.
|
|
44
|
+
function shownLength(preview: Preview): number {
|
|
45
|
+
if (preview.toolName !== "Edit") return [...preview.content].length;
|
|
46
|
+
return preview.hunks.reduce((sum, hunk) => sum + hunk.lines.reduce((lines, line) => lines + [...line].length + 1, 0), 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function Notes({ notes }: { notes: string[] }) {
|
|
50
|
+
if (notes.length === 0) return null;
|
|
51
|
+
return <Alert status="default" className="pv-alert"><Alert.Indicator /><Alert.Content>{notes.map(note => <Alert.Description key={note}>{note}</Alert.Description>)}</Alert.Content></Alert>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Alert, Button } from "@heroui/react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { useWorkStreamSource } from "../../lib/work-stream-source";
|
|
4
|
+
import { ToolOutputPreview } from "./tool-output-preview";
|
|
5
|
+
import type { ToolPreview } from "./work-message-flow";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A Read, Write or Edit call in the reading area, shown like an opened file:
|
|
9
|
+
* what it read, wrote or changed, never its original input or output.
|
|
10
|
+
*/
|
|
11
|
+
export function ToolPreviewReading({ tool, toolbar }: { tool: ToolPreview; toolbar: HTMLElement | null }) {
|
|
12
|
+
const streamClient = useWorkStreamSource();
|
|
13
|
+
const detail = useQuery({
|
|
14
|
+
queryKey: ["tool-preview", tool.workId, tool.toolCallId],
|
|
15
|
+
// The preview comes with the first page; one code point of each side is
|
|
16
|
+
// enough to tell a recorded result from one still to come.
|
|
17
|
+
queryFn: ({ signal }) => streamClient.getToolDetail({ agentWorkId: tool.workId, toolCallId: tool.toolCallId, inputOffset: 0, outputOffset: 0, limit: 1 }, { signal }),
|
|
18
|
+
gcTime: 0, staleTime: Infinity, retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false,
|
|
19
|
+
refetchInterval: query => query.state.data && !query.state.data.output && !query.state.error ? 5000 : false,
|
|
20
|
+
});
|
|
21
|
+
return <div className="pr-file-document">
|
|
22
|
+
{detail.isPending && <p className="sr-only" role="status">Loading preview…</p>}
|
|
23
|
+
{detail.isError && <Alert status="danger" className="pv-alert" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Could not load the preview.</Alert.Description></Alert.Content><Button size="sm" variant="secondary" onPress={() => void detail.refetch()}>Retry preview</Button></Alert>}
|
|
24
|
+
{detail.data && (detail.data.preview ? <ToolOutputPreview preview={detail.data.preview} toolbar={toolbar} />
|
|
25
|
+
: <Alert status="default" className="pv-alert" role="status"><Alert.Indicator /><Alert.Content><Alert.Description>{detail.data.output ? "This call has nothing to preview." : "Waiting for this call to finish…"}</Alert.Description></Alert.Content></Alert>)}
|
|
26
|
+
</div>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { Alert, Button } from "@heroui/react";
|
|
4
|
+
import { useWorkStreamSource } from "../../lib/work-stream-source";
|
|
5
|
+
import type { ToolPreview } from "./work-message-flow";
|
|
6
|
+
import { detailPageSize, MessageDetailContent } from "./tool-message-detail";
|
|
7
|
+
|
|
8
|
+
/** One call's original input and output, opened inline in the conversation. */
|
|
9
|
+
export function ToolReadingContent({ tool }: { tool: ToolPreview }) {
|
|
10
|
+
const streamClient = useWorkStreamSource();
|
|
11
|
+
const [offsets, setOffsets] = useState({ input: 0, output: 0 });
|
|
12
|
+
const detail = useQuery({
|
|
13
|
+
queryKey: ["tool-detail", tool.workId, tool.toolCallId, offsets.input, offsets.output],
|
|
14
|
+
queryFn: ({ signal }) => streamClient.getToolDetail({ agentWorkId: tool.workId, toolCallId: tool.toolCallId, inputOffset: offsets.input, outputOffset: offsets.output, limit: detailPageSize }, { signal }),
|
|
15
|
+
gcTime: 0, staleTime: Infinity, retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false,
|
|
16
|
+
refetchInterval: query => query.state.data && !query.state.data.output && !query.state.error ? 5000 : false,
|
|
17
|
+
placeholderData: previous => previous,
|
|
18
|
+
});
|
|
19
|
+
const pages = detail.data;
|
|
20
|
+
return <div className="wm-tool-reader wm-tool-detail">
|
|
21
|
+
{detail.isPending && <p className="sr-only" role="status">Loading detail…</p>}
|
|
22
|
+
{detail.isError && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Could not load detail.</Alert.Description></Alert.Content><Button variant="ghost" size="sm" onPress={() => void detail.refetch()}>Retry</Button></Alert>}
|
|
23
|
+
{pages && (["input", "output"] as const).map(side => {
|
|
24
|
+
const page = pages[side];
|
|
25
|
+
return <section key={side} aria-label={side === "input" ? "Tool input" : "Tool output"}>
|
|
26
|
+
<h4>{side === "input" ? "Input" : "Output"}</h4>
|
|
27
|
+
{page ? <MessageDetailContent emptyLabel={side === "input" ? "Empty input" : "Empty output"} detail={page} offset={offsets[side]} onPage={offset => setOffsets(current => ({ ...current, [side]: offset }))} /> : <p>{side === "input" ? "Input unavailable" : "No output yet"}</p>}
|
|
28
|
+
</section>;
|
|
29
|
+
})}
|
|
30
|
+
</div>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Button, Tooltip } from "@heroui/react";
|
|
2
|
+
import { List } from "lucide-react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { useReadingArea } from "../shell/reading-context";
|
|
6
|
+
import "./context-rail.css";
|
|
7
|
+
|
|
8
|
+
/** The Work context card every Work page shows beside its conversation: the
|
|
9
|
+
* card collapses from the top bar's toggle, and closing a reading in the
|
|
10
|
+
* reading area makes room for it. The toggle and card exist only while some
|
|
11
|
+
* section has something to show; `beside` holds what stays outside the card.
|
|
12
|
+
* A Project's Home shows the Project in the same card under its own `label`. */
|
|
13
|
+
export function WorkContextRail({ label = "Work context", hasContent, collapsed, onCollapsedChange, beside, children }: { label?: string; hasContent: boolean; collapsed: boolean; onCollapsedChange: (collapsed: boolean) => void; beside?: ReactNode; children: ReactNode }) {
|
|
14
|
+
const reader = useReadingArea();
|
|
15
|
+
const shown = !reader.content && !collapsed;
|
|
16
|
+
return <aside className="cr-rail" data-collapsed={!shown} aria-label={label}>
|
|
17
|
+
{/* The context toggle has a slot of its own so it stays rightmost whichever
|
|
18
|
+
topbar buttons appear first. */}
|
|
19
|
+
{hasContent && reader.contextToggleToolbar && createPortal(<Tooltip><Button aria-label={label} aria-pressed={shown} variant={shown ? "tertiary" : "ghost"} size="sm" isIconOnly onPress={() => { if (reader.content) reader.close(); onCollapsedChange(shown); }}><List size={17} aria-hidden="true" /></Button><Tooltip.Content>{label}</Tooltip.Content></Tooltip>, reader.contextToggleToolbar)}
|
|
20
|
+
{beside}
|
|
21
|
+
{hasContent && <div className="cr-card" hidden={!shown}>{children}</div>}
|
|
22
|
+
</aside>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { StartupProgress } from "@talgent/workbench-contracts/agent/public/v1/startup_pb";
|
|
2
|
+
import type { WorkStartupRequest } from "@talgent/workbench-contracts/agent/public/v1/work_pb";
|
|
3
|
+
import type { WorkModelOption, WorkModelSelection } from "@talgent/workbench-contracts/agent/public/v1/model_pb";
|
|
4
|
+
import { PermissionMode } from "@talgent/workbench-contracts/agent/public/v1/harness_pb";
|
|
5
|
+
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
6
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
7
|
+
import { Alert, Button } from "@heroui/react";
|
|
8
|
+
import { useCallback, useRef, useState, type ReactNode } from "react";
|
|
9
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
10
|
+
import { StartupProgressPanel } from "../session/startup-progress";
|
|
11
|
+
import type { WorkComputer } from "../../lib/computer-service";
|
|
12
|
+
import { WorkPermissionControl } from "../session/work-permission-control";
|
|
13
|
+
import { WorkThinkingControl } from "../session/work-thinking-control";
|
|
14
|
+
import { useReadingArea } from "../shell/reading-context";
|
|
15
|
+
import { useWorkLifecycleControls } from "../../hooks/use-work-lifecycle-controls";
|
|
16
|
+
import { useWorkConversation } from "../../hooks/use-work-conversation";
|
|
17
|
+
import { agentWorkClient } from "../../lib/client";
|
|
18
|
+
import type { WorkStartInput } from "../../lib/start-work-request";
|
|
19
|
+
import { newestStartupProgress } from "../../lib/startup-progress";
|
|
20
|
+
import { adaptSubAgents, type SubAgent } from "../../lib/subagents";
|
|
21
|
+
import { workModelOption as presentedModel } from "../../lib/work-model";
|
|
22
|
+
import { stopReasonNotice } from "../../lib/work-state-presentation";
|
|
23
|
+
import { awaitingReconnectionNotice, computerOfflineMessage } from "../../lib/work-projection";
|
|
24
|
+
import { WorkMessageFlow, type ToolPreview } from "./work-message-flow";
|
|
25
|
+
import { WorkInteraction } from "./work-interaction";
|
|
26
|
+
import { WorkPromptBar } from "./work-prompt-bar";
|
|
27
|
+
import { WorkStatusControl } from "./work-status-control";
|
|
28
|
+
|
|
29
|
+
/** The Work's live Turn and requests are not observed now: Agent answers
|
|
30
|
+
* Unavailable during a Disconnection or while Dispatcher reattaches. */
|
|
31
|
+
const unobserved = (error: unknown) => Boolean(error) && ConnectError.from(error).code === Code.Unavailable;
|
|
32
|
+
|
|
33
|
+
export interface WorkConversationProps {
|
|
34
|
+
/** The Computer the Work runs on. */
|
|
35
|
+
computer?: WorkComputer;
|
|
36
|
+
agentWorkId: string;
|
|
37
|
+
/** What the Work is for, the first crumb of what it opens in the reading area. */
|
|
38
|
+
label: string;
|
|
39
|
+
/** Names what the Work is for while it starts. */
|
|
40
|
+
startupTitle?: string;
|
|
41
|
+
workLifecycle?: string;
|
|
42
|
+
/** Why the Work stopped, when it is Stopped. */
|
|
43
|
+
stateReason?: string;
|
|
44
|
+
/** Why a Failed Work ended, as Agent recorded it. */
|
|
45
|
+
failureReason?: string;
|
|
46
|
+
/** When the Work was disconnected; absent while observed. */
|
|
47
|
+
disconnectedSince?: Date;
|
|
48
|
+
/** The Stopped Work keeps its messages for its Managed Computer's return
|
|
49
|
+
* and resumes with them when it reconnects. */
|
|
50
|
+
resumeOnReconnection?: boolean;
|
|
51
|
+
workControlPending?: boolean;
|
|
52
|
+
startupProgress?: StartupProgress;
|
|
53
|
+
startupRequest?: WorkStartupRequest;
|
|
54
|
+
workModel?: WorkModelSelection;
|
|
55
|
+
/** The retained Model Configuration as currently provided; absent when withdrawn. */
|
|
56
|
+
workModelOption?: WorkModelOption;
|
|
57
|
+
allowedActions?: string[];
|
|
58
|
+
/** A Managed Work's permission mode; unspecified for a Cloud Work. */
|
|
59
|
+
permissionMode?: PermissionMode;
|
|
60
|
+
/** The Work runs in Claude Code's sandbox for its life. */
|
|
61
|
+
sandbox?: boolean;
|
|
62
|
+
/** The Work's start waits in its Computer's line for room. */
|
|
63
|
+
deskWaiting?: boolean;
|
|
64
|
+
workVersion?: bigint;
|
|
65
|
+
onWorkControlChange?: () => void;
|
|
66
|
+
onWorkLifecycleChange?: (lifecycle: string, updatedAt?: Date, startupProgress?: StartupProgress) => void;
|
|
67
|
+
/** The Work's SubAgents from each state frame of this stream. */
|
|
68
|
+
onSubAgentsChange?: (agents: SubAgent[]) => void;
|
|
69
|
+
/** Starts the Work again with its first startup request after admission failed. */
|
|
70
|
+
onRetryStart?: (input: WorkStartInput) => Promise<void>;
|
|
71
|
+
/** Adds a file to the Work; absent where the Work takes none. */
|
|
72
|
+
onUploadAttachment?: (file: File) => Promise<unknown>;
|
|
73
|
+
/** The Work's snapshot is still loading and no live state has arrived. */
|
|
74
|
+
isWorkLifecyclePending?: boolean;
|
|
75
|
+
/** A reader who is not the owner: the conversation renders without the
|
|
76
|
+
* prompt bar, responses or controls. */
|
|
77
|
+
readOnly?: boolean;
|
|
78
|
+
/** The reader's access was withdrawn while reading. */
|
|
79
|
+
onAccessWithdrawn?: () => void;
|
|
80
|
+
/** Says the Work's state shown is the last one read, when refreshing fails. */
|
|
81
|
+
snapshotNotice?: ReactNode;
|
|
82
|
+
/** Replaces the Work's own status control with one its page supplies. */
|
|
83
|
+
statusControl?: ReactNode;
|
|
84
|
+
/** Shown in place of the prompt bar once the Work is closed. */
|
|
85
|
+
closedText?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A started Work's conversation: its startup until it is ready, then its
|
|
89
|
+
* messages, the request awaiting the owner and the prompt bar. */
|
|
90
|
+
export function WorkConversation({ computer, agentWorkId, label, startupTitle, workLifecycle = "", stateReason, failureReason, disconnectedSince, resumeOnReconnection = false, workControlPending = false, startupProgress, startupRequest, workModel, workModelOption, workVersion, allowedActions = [], permissionMode = PermissionMode.UNSPECIFIED, sandbox = false, deskWaiting = false, onWorkControlChange, onWorkLifecycleChange, onSubAgentsChange, onRetryStart, onUploadAttachment, isWorkLifecyclePending = false, readOnly = false, onAccessWithdrawn, snapshotNotice, statusControl, closedText = "This Work has ended" }: WorkConversationProps) {
|
|
91
|
+
const cache = useQueryClient();
|
|
92
|
+
const { open: openReader } = useReadingArea();
|
|
93
|
+
const pendingInput = useRef<{ text: string; id: string } | null>(null);
|
|
94
|
+
const [liveStartup, setLiveStartup] = useState<StartupProgress>();
|
|
95
|
+
const processing = workLifecycle === "working";
|
|
96
|
+
const model = presentedModel(workModel, workModelOption);
|
|
97
|
+
// Turn and request controls are the owner's; a reader never queries them.
|
|
98
|
+
// During a Disconnection neither is observed, so neither is read; the
|
|
99
|
+
// Disconnection shown on the Work explains it.
|
|
100
|
+
const disconnected = Boolean(disconnectedSince);
|
|
101
|
+
const controlling = processing && !readOnly;
|
|
102
|
+
const observing = controlling && !disconnected;
|
|
103
|
+
const turn = useQuery({
|
|
104
|
+
queryKey: ["work-current-turn", agentWorkId], enabled: Boolean(agentWorkId && observing),
|
|
105
|
+
queryFn: ({ signal }) => agentWorkClient.getCurrentTurn({ workId: agentWorkId! }, { signal }),
|
|
106
|
+
retry: false, refetchInterval: observing ? 2000 : false, refetchOnWindowFocus: false,
|
|
107
|
+
});
|
|
108
|
+
const interactions = useQuery({
|
|
109
|
+
queryKey: ["work-pending-interactions", agentWorkId], enabled: Boolean(agentWorkId && observing),
|
|
110
|
+
queryFn: ({ signal }) => agentWorkClient.listPendingInteractions({ workId: agentWorkId! }, { signal }),
|
|
111
|
+
retry: false, refetchInterval: observing ? 2000 : false, refetchOnWindowFocus: false, gcTime: 0,
|
|
112
|
+
});
|
|
113
|
+
// What was last read is not current while unobserved, so it is not shown.
|
|
114
|
+
const turnObserved = processing && !disconnected && !unobserved(turn.error);
|
|
115
|
+
const requestsObserved = processing && !disconnected && !unobserved(interactions.error);
|
|
116
|
+
const refreshControls = useCallback(() => {
|
|
117
|
+
void cache.invalidateQueries({ queryKey: ["work-current-turn", agentWorkId] });
|
|
118
|
+
void cache.invalidateQueries({ queryKey: ["work-pending-interactions", agentWorkId] });
|
|
119
|
+
}, [cache, agentWorkId]);
|
|
120
|
+
const conversation = useWorkConversation(agentWorkId ?? undefined, state => {
|
|
121
|
+
if (state.preparationProgress) setLiveStartup(state.preparationProgress);
|
|
122
|
+
onWorkLifecycleChange?.(state.state, state.updatedAt ? timestampDate(state.updatedAt) : undefined, state.preparationProgress);
|
|
123
|
+
onSubAgentsChange?.(adaptSubAgents(state.subagents));
|
|
124
|
+
if (state.workProgress) onWorkControlChange?.();
|
|
125
|
+
refreshControls();
|
|
126
|
+
}, () => { refreshControls(); onWorkControlChange?.(); }, refreshControls, onAccessWithdrawn);
|
|
127
|
+
const currentStartup = newestStartupProgress(liveStartup, startupProgress);
|
|
128
|
+
const currentTurnId = turnObserved ? turn.data?.turnId : undefined;
|
|
129
|
+
const currentRequest = requestsObserved ? interactions.data?.interactions.find(item => !item.replySubmitted) ?? interactions.data?.interactions[0] : undefined;
|
|
130
|
+
const requiredResponse = Boolean(requestsObserved && interactions.data?.interactions.some(item => !item.replySubmitted));
|
|
131
|
+
const applyWork = (work?: { state: string; updatedAt?: Parameters<typeof timestampDate>[0]; startupProgress?: StartupProgress }) => {
|
|
132
|
+
if (!work) return;
|
|
133
|
+
onWorkLifecycleChange?.(work.state, work.updatedAt ? timestampDate(work.updatedAt) : undefined, work.startupProgress);
|
|
134
|
+
};
|
|
135
|
+
const controls = useWorkLifecycleControls(agentWorkId, applyWork, () => { onWorkControlChange?.(); refreshControls(); }, workVersion);
|
|
136
|
+
const { pending: actionPending, error: controlError, run: runAction, resume, continueWithInput, restart, setArchiveOpen } = controls;
|
|
137
|
+
// A Work awaiting its Computer's return says so itself, so its refused
|
|
138
|
+
// Resume adds no error of its own; once the Work is no longer stopped, the
|
|
139
|
+
// refusal no longer describes it.
|
|
140
|
+
const awaitingReconnection = resumeOnReconnection && workLifecycle === "stopped";
|
|
141
|
+
const actionError = controlError === computerOfflineMessage && (awaitingReconnection || workLifecycle !== "stopped") ? "" : controlError;
|
|
142
|
+
const controlsPending = workControlPending || actionPending;
|
|
143
|
+
// Input sent while a Turn runs is Steer. A required response holds the
|
|
144
|
+
// composer; its reply card stays usable. A Work not closed takes input:
|
|
145
|
+
// the owner's message to a stopped Work also resumes it.
|
|
146
|
+
// An unobserved Work takes it too: its buffer holds it for the Harness.
|
|
147
|
+
const resumable = workLifecycle === "stopped" && allowedActions.includes("resume") && !controlsPending;
|
|
148
|
+
const stopNotice = stopReasonNotice(workLifecycle, stateReason);
|
|
149
|
+
const canSend = (processing && (!requestsObserved || (interactions.isSuccess && !requiredResponse)) && !controlsPending) || resumable;
|
|
150
|
+
const openTool = useCallback((tool: ToolPreview, trigger: HTMLElement) => openReader({ kind: "tool", label, tool }, trigger), [openReader, label]);
|
|
151
|
+
// A running Work's settings change only while it runs and its controls are free.
|
|
152
|
+
const canAdjust = processing && allowedActions.includes("stop") && !controlsPending;
|
|
153
|
+
const thinkingControl = workModel ? <WorkThinkingControl sessionId={agentWorkId} option={model} initialLevel={startupRequest?.initialThinkingLevel} canAdjust={canAdjust} /> : undefined;
|
|
154
|
+
const workStatus = !readOnly && (statusControl ?? <WorkStatusControl workLifecycle={workLifecycle} stateReason={stateReason} disconnectedSince={disconnectedSince} allowedActions={allowedActions} disabled={workControlPending} controls={controls} />);
|
|
155
|
+
|
|
156
|
+
if (workLifecycle !== "closed" && currentStartup && currentStartup.status !== "completed") {
|
|
157
|
+
const retryInitial = !readOnly && workLifecycle === "created" && currentStartup.status === "failed" && !currentStartup.admitted && allowedActions.includes("retry_start") && startupRequest && onRetryStart;
|
|
158
|
+
const canRestart = !readOnly && workLifecycle === "failed" && allowedActions.includes("restart");
|
|
159
|
+
const retry = retryInitial ? () => void runAction(() => onRetryStart!({ initialMessage: startupRequest!.initialMessage, model: workModel, thinkingLevel: startupRequest!.initialThinkingLevel, repositorySelections: startupRequest!.repositories.map(repository => ({ repositoryId: repository.repositoryId, baseBranch: repository.baseBranch })) }))
|
|
160
|
+
: canRestart ? () => void restart() : !readOnly && workLifecycle === "stopped" && allowedActions.includes("resume") ? () => void resume() : undefined;
|
|
161
|
+
return <section data-testid="agent-stream-panel" className="stream-panel stream-panel-starting" aria-label="Agent Stream">{workStatus}{snapshotNotice}<StartupProgressPanel computer={computer} progress={currentStartup} waitingForRoom={deskWaiting && workLifecycle === "starting"} workTitle={startupTitle} request={startupRequest} model={model} onRetry={retry} retryLabel={retryInitial ? "Retry startup" : canRestart ? "Restart Work" : "Resume Work"} retryDescription={canRestart ? "Restart continues this Work using its saved conversation when available." : undefined} retryPending={controlsPending} retryError={actionError} onArchive={!readOnly && allowedActions.includes("archive") && ["failed", "stopped"].includes(workLifecycle) && !workControlPending ? () => setArchiveOpen(true) : undefined} /></section>;
|
|
162
|
+
}
|
|
163
|
+
// Until the Work's snapshot arrives, a newly started Work shows startup, never an empty conversation.
|
|
164
|
+
if (!currentStartup && (isWorkLifecyclePending || ["created", "starting"].includes(workLifecycle))) return <section data-testid="agent-stream-panel" className="stream-panel stream-panel-starting" aria-label="Agent Stream">{workStatus}{snapshotNotice}<p className="sr-only" role="status">Loading startup progress…</p></section>;
|
|
165
|
+
|
|
166
|
+
return <section data-testid="agent-stream-panel" className="stream-panel" aria-label="Agent Stream">
|
|
167
|
+
{workStatus}
|
|
168
|
+
{snapshotNotice}
|
|
169
|
+
<WorkMessageFlow conversation={conversation} currentTurnId={currentTurnId} waiting={requiredResponse} onOpenTool={openTool} />
|
|
170
|
+
{readOnly ? conversation.error && <div className="wi-notices"><Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Message history could not be loaded.</Alert.Description></Alert.Content><Button size="sm" variant="ghost" onPress={conversation.retry}>Retry</Button></Alert></div> : <div className="wi-dock">
|
|
171
|
+
{currentRequest && <WorkInteraction key={currentRequest.id} workId={agentWorkId} request={currentRequest} disabled={controlsPending} onResponded={refreshControls} />}
|
|
172
|
+
{workLifecycle === "failed" && failureReason && <p className="wpb-feedback" role="status">This Work failed. {failureReason}</p>}
|
|
173
|
+
{stopNotice && <p className="wpb-feedback" role="status">{stopNotice}{allowedActions.includes("resume") && !awaitingReconnection && " Resume continues the conversation."}</p>}
|
|
174
|
+
{awaitingReconnection && <p className="wpb-feedback" role="status">{awaitingReconnectionNotice(computer?.name)}</p>}
|
|
175
|
+
{turnObserved && turn.data?.active && !currentTurnId && <p className="wpb-feedback" role="status">Starting turn…</p>}
|
|
176
|
+
{turnObserved && turn.data?.cancelRequested && <p className="wpb-feedback" role="status">Cancelling turn…</p>}
|
|
177
|
+
{(conversation.error || (turnObserved && turn.isError) || (requestsObserved && interactions.isError)) && <div className="wi-notices">
|
|
178
|
+
{turnObserved && turn.isError && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Current turn unavailable.</Alert.Description></Alert.Content><Button size="sm" variant="ghost" onPress={() => void turn.refetch()}>Retry</Button></Alert>}
|
|
179
|
+
{requestsObserved && interactions.isError && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Could not load the current request.</Alert.Description></Alert.Content><Button size="sm" variant="ghost" onPress={() => void interactions.refetch()}>Retry</Button></Alert>}
|
|
180
|
+
{conversation.error && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Message history could not be loaded.</Alert.Description></Alert.Content><Button size="sm" variant="ghost" onPress={conversation.retry}>Retry</Button></Alert>}
|
|
181
|
+
</div>}
|
|
182
|
+
<WorkPromptBar repositories={startupRequest?.repositories} workId={agentWorkId} active={processing} disabled={false} mode={processing || resumable ? "send" : "disabled"} sendDisabled={!canSend}
|
|
183
|
+
workActionPending={controlsPending || Boolean(currentTurnId && turn.data?.cancelRequested)} workActionError={actionError}
|
|
184
|
+
placeholder={requiredResponse ? "Answer the request above to continue" : currentTurnId ? "Add to this turn" : processing && disconnected ? "Delivered once the Work reconnects" : "Work on anything"} unavailableText={workLifecycle === "closed" ? closedText : undefined} inputLabel="Instruction" sendLabel="Send instruction"
|
|
185
|
+
onSend={async text => {
|
|
186
|
+
if (!canSend) throw new Error(requiredResponse ? "Answer the request above first." : "Work is not ready for another instruction.");
|
|
187
|
+
if (!pendingInput.current || pendingInput.current.text !== text) pendingInput.current = { text, id: crypto.randomUUID() };
|
|
188
|
+
const input = pendingInput.current;
|
|
189
|
+
await agentWorkClient.sendMessage({ workId: agentWorkId, text, inputId: input.id });
|
|
190
|
+
if (pendingInput.current === input) pendingInput.current = null;
|
|
191
|
+
// A stopped Work keeps the message for its next start; resuming reads it.
|
|
192
|
+
if (resumable) { await continueWithInput(); return; }
|
|
193
|
+
// Keep the send lock until the authoritative post-admission state
|
|
194
|
+
// arrives; an unobserved Work has none to read.
|
|
195
|
+
if (observing) await turn.refetch();
|
|
196
|
+
}}
|
|
197
|
+
onCancelTurn={currentTurnId ? async () => {
|
|
198
|
+
await agentWorkClient.cancelTurn({ workId: agentWorkId, turnId: currentTurnId });
|
|
199
|
+
await turn.refetch();
|
|
200
|
+
} : undefined}
|
|
201
|
+
onUploadAttachment={workLifecycle === "closed" ? undefined : onUploadAttachment}
|
|
202
|
+
modelControl={permissionMode ? <div className="wpb-work-controls">
|
|
203
|
+
<WorkPermissionControl workId={agentWorkId} mode={permissionMode} sandbox={sandbox} canAdjust={canAdjust && allowedActions.includes("set_permission_mode")} starterOnly={processing && !allowedActions.includes("set_permission_mode")} />{thinkingControl}
|
|
204
|
+
</div> : thinkingControl} />
|
|
205
|
+
</div>}
|
|
206
|
+
</section>;
|
|
207
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { workPresentation } from "../../lib/work-state-presentation";
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
|
+
import { typographyVariants, Button, Chip, Dropdown, Label, ListBox, Modal, Spinner, Tooltip, Typography } from "@heroui/react";
|
|
4
|
+
import { Archive, Circle, Ellipsis, Folder, Pause, Play } from "lucide-react";
|
|
5
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
7
|
+
import { agentWorkClient } from "../../lib/client";
|
|
8
|
+
import { agentWorkQueryKey } from "../../lib/agent-work-service";
|
|
9
|
+
import { isStaleWorkControl, workControlErrorMessage } from "../../lib/work-projection";
|
|
10
|
+
|
|
11
|
+
/** One recent Work: what it is for, its state and why it stopped, since when
|
|
12
|
+
* it is disconnected (when it is) and where it opens. */
|
|
13
|
+
export type WorkHistoryRow = { id: string; title: string; state: string; waiting: boolean; stateReason?: string; disconnectedSince?: Date | null; to: string };
|
|
14
|
+
|
|
15
|
+
/** The recent Works as the entry lists them, and what refreshes once one is controlled. */
|
|
16
|
+
export type WorkHistorySource = {
|
|
17
|
+
data: WorkHistoryRow[] | undefined;
|
|
18
|
+
isLoading: boolean;
|
|
19
|
+
error: Error | null;
|
|
20
|
+
refetch: () => unknown;
|
|
21
|
+
/** Query keys whose answers change with a Work's state. */
|
|
22
|
+
affects: ReadonlyArray<readonly unknown[]>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Recent in the sidebar: the person's recent Works, each opening its page,
|
|
26
|
+
* with the owner's stop, resume and archive. */
|
|
27
|
+
export function WorkHistory({ history, activeId, onNavigate }: { history: WorkHistorySource; activeId: string | null; onNavigate: (path: string) => void }) {
|
|
28
|
+
const [hoverId, setHoverId] = useState<string | null>(null);
|
|
29
|
+
const [menuId, setMenuId] = useState<string | null>(null);
|
|
30
|
+
const [archiveTarget, setArchiveTarget] = useState<WorkHistoryRow | null>(null);
|
|
31
|
+
const inFlight = useRef(false);
|
|
32
|
+
const queryClient = useQueryClient();
|
|
33
|
+
const targetId = archiveTarget?.id ?? menuId;
|
|
34
|
+
const snapshot = useQuery({
|
|
35
|
+
queryKey: agentWorkQueryKey(targetId),
|
|
36
|
+
queryFn: () => agentWorkClient.getWork({ workId: targetId ?? "" }),
|
|
37
|
+
enabled: Boolean(targetId),
|
|
38
|
+
staleTime: 0,
|
|
39
|
+
retry: false,
|
|
40
|
+
refetchInterval: targetId ? 5000 : false,
|
|
41
|
+
});
|
|
42
|
+
const control = useMutation({
|
|
43
|
+
mutationFn: async ({ id, action }: { id: string; action: "stop" | "resume" | "archive" }) => {
|
|
44
|
+
if (inFlight.current) return;
|
|
45
|
+
inFlight.current = true;
|
|
46
|
+
try {
|
|
47
|
+
// The version of the state this menu offered; a changed Work is refused.
|
|
48
|
+
const request = { workId: id, expectedVersion: snapshot.data?.work?.version };
|
|
49
|
+
if (action === "stop") await agentWorkClient.stopWork(request);
|
|
50
|
+
else if (action === "resume") await agentWorkClient.resumeWork(request);
|
|
51
|
+
else await agentWorkClient.archiveWork(request);
|
|
52
|
+
if (action === "archive") setArchiveTarget(null);
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
if (action === "archive" && isStaleWorkControl(cause)) setArchiveTarget(null);
|
|
55
|
+
throw cause;
|
|
56
|
+
} finally { inFlight.current = false; }
|
|
57
|
+
},
|
|
58
|
+
onSettled: async (_, __, { id }) => {
|
|
59
|
+
await Promise.all([agentWorkQueryKey(id), ...history.affects].map(queryKey => queryClient.invalidateQueries({ queryKey })));
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const session = snapshot.data?.work;
|
|
63
|
+
const allowed = snapshot.isError || snapshot.isFetching || control.isPending || session?.archivePending || session?.resumePending ? [] : session?.allowedActions ?? [];
|
|
64
|
+
const accessLost = history.error && [Code.PermissionDenied, Code.Unauthenticated].includes(ConnectError.from(history.error).code);
|
|
65
|
+
const rows = accessLost ? [] : history.data ?? [];
|
|
66
|
+
return <section className="p-session-history" aria-label="History">
|
|
67
|
+
{/* Archived Works stay in Recent, marked by their Closed state. */}
|
|
68
|
+
<Typography type="body-sm" color="muted" className="p-history-heading">Recent</Typography>
|
|
69
|
+
{history.error && <div role="alert"><Typography type="body-sm">History unavailable</Typography><Button size="sm" variant="ghost" onPress={() => { void history.refetch(); }}>Retry history</Button></div>}
|
|
70
|
+
{control.isError && !archiveTarget && <Typography type="body-sm" className="text-danger" role="alert">{workControlErrorMessage(control.error)}</Typography>}
|
|
71
|
+
{/* Selection marks the open Work, so choosing a row navigates on selection
|
|
72
|
+
change: with toggle selection a click on a list that already has a
|
|
73
|
+
selected row changes selection and never fires onAction. */}
|
|
74
|
+
<div className="p-history-list"><div className="p-session-rows" onMouseLeave={() => setHoverId(null)} onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) setHoverId(null); }}>
|
|
75
|
+
<ListBox className="p-session-list" aria-label="Recent" selectionMode="single" disallowEmptySelection selectedKeys={activeId && rows.some(row => row.id === activeId) ? [activeId] : []} onSelectionChange={keys => { const id = keys === "all" ? undefined : [...keys][0]; const row = rows.find(item => item.id === id); if (row && row.id !== activeId) onNavigate(row.to); }} renderEmptyState={() => <Typography type="body-sm" color="muted" className={history.isLoading ? "sr-only" : "p-sidebar-empty"}>{history.isLoading ? "Loading history…" : history.error ? "" : "No recent work"}</Typography>}>
|
|
76
|
+
{rows.map(row => {
|
|
77
|
+
// A forgotten question outranks the lifecycle, so the row asks for the
|
|
78
|
+
// person even after the Work stopped.
|
|
79
|
+
const presentation = workPresentation(row);
|
|
80
|
+
const Icon = presentation?.icon ?? Circle;
|
|
81
|
+
const label = presentation?.label ?? "Unknown state";
|
|
82
|
+
return <ListBox.Item key={row.id} id={row.id} textValue={row.title} className="p-session-item" data-waiting={row.waiting || undefined} data-actions-visible={hoverId === row.id || menuId === row.id} onHoverStart={() => setHoverId(row.id)} onFocus={() => setHoverId(row.id)}>
|
|
83
|
+
<Chip size="sm" variant="soft" color={presentation?.color ?? "default"} aria-hidden="true">{!row.waiting && !row.disconnectedSince && (row.state === "starting" || row.state === "stopping") ? <Spinner size="sm" color="current" /> : <Icon size={16} />}</Chip>
|
|
84
|
+
<Tooltip><Tooltip.Trigger role="presentation" tabIndex={-1} className="p-session-label"><Label><span className={typographyVariants({ type: "body-sm", color: row.id === activeId || row.waiting ? "default" : "muted", truncate: true }).base()} >{row.title}<span className="sr-only"> · {label}{presentation?.description && `. ${presentation.description}`}</span></span></Label></Tooltip.Trigger><Tooltip.Content placement="right" offset={48} className="p-session-tooltip">{row.title} · {label}{presentation?.description && <span className="block text-muted">{presentation.description}</span>}</Tooltip.Content></Tooltip>
|
|
85
|
+
</ListBox.Item>;
|
|
86
|
+
})}
|
|
87
|
+
</ListBox>
|
|
88
|
+
<div className="p-session-actions">{rows.map(row => <div key={row.id} className="p-session-action" data-visible={hoverId === row.id || menuId === row.id}>
|
|
89
|
+
<Dropdown onOpenChange={open => { setMenuId(open ? row.id : null); setHoverId(row.id); }}>
|
|
90
|
+
<Button size="sm" variant="ghost" isIconOnly aria-label={`Actions for ${row.id}`}><Ellipsis size={16} /></Button>
|
|
91
|
+
<Dropdown.Popover><Dropdown.Menu aria-label={`Actions for ${row.id}`} onAction={key => {
|
|
92
|
+
if (key === "open") onNavigate(row.to);
|
|
93
|
+
else if (key === "retry") void snapshot.refetch();
|
|
94
|
+
else if (key === "archive") { control.reset(); setArchiveTarget(row); }
|
|
95
|
+
else if ((key === "stop" || key === "resume") && allowed.includes(key)) control.mutate({ id: row.id, action: key });
|
|
96
|
+
}}>
|
|
97
|
+
<Dropdown.Item id="open" textValue="Open work"><Folder size={16} /><Label>Open work</Label></Dropdown.Item>
|
|
98
|
+
{snapshot.isFetching && <Dropdown.Item id="loading" isDisabled textValue="Checking actions"><Spinner size="sm" /><Label>Checking actions</Label></Dropdown.Item>}
|
|
99
|
+
{snapshot.isError && <Dropdown.Item id="retry" textValue="Retry actions"><Label>Retry actions</Label></Dropdown.Item>}
|
|
100
|
+
{allowed.includes("resume") && <Dropdown.Item id="resume" textValue="Resume work"><Play size={16} /><Label>Resume work</Label></Dropdown.Item>}
|
|
101
|
+
{allowed.includes("stop") && <Dropdown.Item id="stop" textValue="Stop work"><Pause size={16} /><Label>Stop work</Label></Dropdown.Item>}
|
|
102
|
+
{allowed.includes("archive") && <Dropdown.Item id="archive" textValue="Archive work"><Archive size={16} /><Label>Archive work</Label></Dropdown.Item>}
|
|
103
|
+
</Dropdown.Menu></Dropdown.Popover>
|
|
104
|
+
</Dropdown>
|
|
105
|
+
</div>)}</div>
|
|
106
|
+
</div></div>
|
|
107
|
+
<Modal><Modal.Backdrop isOpen={Boolean(archiveTarget)} onOpenChange={open => { if (!open && !control.isPending) setArchiveTarget(null); }} isDismissable={!control.isPending} isKeyboardDismissDisabled={control.isPending}>
|
|
108
|
+
<Modal.Container placement="center" size="sm"><Modal.Dialog>
|
|
109
|
+
<Modal.Header><Modal.Heading>Archive work?</Modal.Heading></Modal.Header>
|
|
110
|
+
<Modal.Body><Typography type="body-sm">Archive “{archiveTarget?.title}”? This permanently closes the conversation and removes its working files. Conversation history, attachment originals, published files and your home are kept.</Typography>{control.isError && <Typography type="body-sm" className="text-danger" role="alert">{workControlErrorMessage(control.error)}</Typography>}{snapshot.isError && <Button variant="ghost" onPress={() => { void snapshot.refetch(); }}>Retry actions</Button>}</Modal.Body>
|
|
111
|
+
<Modal.Footer><Button autoFocus variant="secondary" isDisabled={control.isPending} onPress={() => setArchiveTarget(null)}>Cancel</Button><Button variant="danger" isPending={control.isPending} isDisabled={!allowed.includes("archive")} onPress={() => { if (archiveTarget && !inFlight.current) control.mutate({ id: archiveTarget.id, action: "archive" }); }}>Archive work</Button></Modal.Footer>
|
|
112
|
+
</Modal.Dialog></Modal.Container>
|
|
113
|
+
</Modal.Backdrop></Modal>
|
|
114
|
+
</section>;
|
|
115
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* Floating interaction cards share the accepted PromptBar's width and bottom anchor. */
|
|
2
|
+
.stream-panel:has(> .wm-scroll) > .wi-dock { position: absolute; inset-inline: 0; bottom: 0; z-index: 20; width: min(var(--wpb-panel-width), calc(100% - 2 * var(--wpb-panel-gutter))); margin-inline: auto; }
|
|
3
|
+
.wi-dock > .wpb-conversation { width: 100%; }
|
|
4
|
+
.wi-card { padding: 12px 14px; border: 1px solid color-mix(in oklch, var(--separator) 55%, transparent); border-radius: 20px; background: var(--surface); box-shadow: 0 4px 24px rgb(0 0 0 / 3%); max-height: calc(100dvh - 200px); overflow-y: auto; }
|
|
5
|
+
.wi-heading { display: flex; align-items: center; justify-content: space-between; min-height: 24px; padding-inline: 2px; color: var(--muted); font-size: 14px; }
|
|
6
|
+
.wi-heading > span, .wi-heading nav { display: flex; align-items: center; gap: 9px; }
|
|
7
|
+
.wi-heading nav { gap: 3px; }
|
|
8
|
+
.wi-heading nav .button { width: 24px; min-width: 24px; height: 24px; color: var(--muted); }
|
|
9
|
+
.wi-count { min-width: 46px; text-align: center; font-size: 13px; font-variant-numeric: tabular-nums; }
|
|
10
|
+
.wi-card h3 { margin: 8px 2px; font-size: 15px; font-weight: 600; line-height: 1.5; outline: none; }
|
|
11
|
+
.wi-choices { display: flex; flex-direction: column; gap: 2px; margin-inline: -4px; }
|
|
12
|
+
.wi-choice { display: flex; position: relative; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 10px; font-size: 15px; line-height: 1.5; cursor: pointer; }
|
|
13
|
+
.wi-choice:hover, .wi-choice[data-selected="true"] { background: var(--surface-secondary); }
|
|
14
|
+
.wi-choice:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: -2px; }
|
|
15
|
+
.wi-choice input { position: absolute; opacity: 0; width: 1px; height: 1px; }
|
|
16
|
+
.wi-choice-number { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 24px; width: 24px; height: 24px; border-radius: 50%; border: 1px solid color-mix(in oklch, var(--separator) 65%, transparent); background: var(--surface-secondary); color: var(--muted); font-size: 14px; }
|
|
17
|
+
.wi-choice-arrow { margin-left: auto; flex-shrink: 0; color: var(--muted); opacity: 0; }
|
|
18
|
+
.wi-choice:hover .wi-choice-arrow, .wi-choice[data-selected="true"] .wi-choice-arrow { opacity: 1; }
|
|
19
|
+
.wi-question-footer { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
|
20
|
+
.wi-custom { display: flex; flex: 1; align-items: center; gap: 10px; min-width: 0; }
|
|
21
|
+
.wi-custom input { width: 100%; min-width: 0; background: transparent; padding-block: 6px; font-size: 15px; outline: none; }
|
|
22
|
+
.wi-custom input::placeholder { color: color-mix(in oklch, var(--muted) 55%, var(--surface)); }
|
|
23
|
+
.wi-custom:focus-within { border-radius: 6px; outline: 2px solid var(--focus); outline-offset: 3px; }
|
|
24
|
+
.wi-card footer .button { border-radius: 999px; flex-shrink: 0; font-size: 14px; }
|
|
25
|
+
.wi-command { margin: 12px 2px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); font-family: var(--font-mono); font-size: 12px; line-height: 1.5; }
|
|
26
|
+
.wi-approval-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; }
|
|
27
|
+
.wi-approval-footer kbd { padding: 0 5px; border-radius: 4px; background: var(--surface-secondary); font-size: 12px; }
|
|
28
|
+
@media (max-width: 740px) { .wi-card { padding: 10px 12px; border-radius: 18px; } .wi-question-footer { flex-wrap: wrap; justify-content: flex-end; } .wi-custom { flex-basis: 100%; margin-bottom: 4px; } }
|
|
29
|
+
|
|
30
|
+
.wi-feedback { margin: 8px 2px 0; color: var(--muted); font-size: 13px; }
|
|
31
|
+
.wi-card .wm-tool-detail pre { max-height: 120px; }
|
|
32
|
+
|
|
33
|
+
.wi-notices { display: grid; gap: 8px; margin-top: 8px; }
|
|
34
|
+
|
|
35
|
+
/* Compact Work notices have one text row beside their indicator and action. */
|
|
36
|
+
.wi-dock .alert { align-items: center; }
|
|
37
|
+
.wi-dock .alert__content { height: auto; min-width: 0; justify-content: center; }
|
|
38
|
+
.wi-dock .alert > :is(.alert__indicator, .button) { flex-shrink: 0; }
|