@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,26 @@
|
|
|
1
|
+
import type { WorkFile } from "@talgent/workbench-contracts/agent/public/v1/work_file_pb";
|
|
2
|
+
import { useCurrentUser } from "../../lib/account-service";
|
|
3
|
+
import { requestWorkFilePreview, useDownloadWorkFile, workFileKey, workFilePreview } from "../../lib/work-file-service";
|
|
4
|
+
import { useOwnWorkUsageStatus } from "../analytics/work-usage/work-status";
|
|
5
|
+
import { WorkUsageReader } from "../analytics/work-usage/work-usage-reader";
|
|
6
|
+
import { FileReading } from "./file-reading";
|
|
7
|
+
import type { ReadingContent, ReadingSlots } from "./reading-context";
|
|
8
|
+
|
|
9
|
+
/** The Work's Token Usage as its owner reads it. */
|
|
10
|
+
export function OwnWorkUsage({ content, slots }: { content: Extract<ReadingContent, { kind: "trace" }>; slots: ReadingSlots }) {
|
|
11
|
+
const status = useOwnWorkUsageStatus(content.workId);
|
|
12
|
+
return <WorkUsageReader key={content.workId} projectId={content.projectId} agentWorkId={content.workId} createdAt={content.createdAt} status={status} toolbar={slots.toolbar} />;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A Work's file, previewed through Agent. */
|
|
16
|
+
export function WorkFileReading({ workId, file, toolbar }: { workId: string; file: WorkFile; toolbar: HTMLElement | null }) {
|
|
17
|
+
const userId = useCurrentUser().data?.user?.id;
|
|
18
|
+
const download = useDownloadWorkFile(workId);
|
|
19
|
+
if (!userId) return null;
|
|
20
|
+
return <FileReading name={file.name} toolbar={toolbar}
|
|
21
|
+
// A replaced file keeps its position; its change time tells new content.
|
|
22
|
+
verdictKey={[userId, workId, workFileKey(file), String(file.updatedAt?.seconds ?? "")]}
|
|
23
|
+
loadVerdict={() => workFilePreview(workId, file)}
|
|
24
|
+
requestRender={() => requestWorkFilePreview(workId, file)}
|
|
25
|
+
download={{ run: () => download.mutate(file), isPending: download.isPending, error: download.error, reset: download.reset }} />;
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { workFileKey } from "../../lib/work-file-service";
|
|
2
|
+
import { ToolPreviewReading } from "../work/tool-preview-reading";
|
|
3
|
+
import { OwnWorkUsage, WorkFileReading } from "./core-reading-views";
|
|
4
|
+
import type { ReadingRenderer } from "./reading-context";
|
|
5
|
+
|
|
6
|
+
/** The core's readings: a tool call's preview, a Work's Token Usage and a
|
|
7
|
+
* Work's file. */
|
|
8
|
+
export const coreReading: ReadingRenderer = content => {
|
|
9
|
+
switch (content.kind) {
|
|
10
|
+
case "tool": return { key: `${content.tool.workId}:${content.tool.toolCallId}`, label: content.label, title: content.tool.title,
|
|
11
|
+
render: ({ toolbar }) => <ToolPreviewReading key={`${content.tool.workId}:${content.tool.toolCallId}`} tool={content.tool} toolbar={toolbar} /> };
|
|
12
|
+
case "trace": return { key: `trace:${content.workId}`, label: content.label, title: "Token Usage", render: slots => <OwnWorkUsage content={content} slots={slots} /> };
|
|
13
|
+
case "work-file": return { key: `${content.workId}:${workFileKey(content.file)}`, label: content.label, title: content.file.name,
|
|
14
|
+
render: ({ toolbar }) => <WorkFileReading key={`${workFileKey(content.file)}:${String(content.file.updatedAt?.seconds ?? "")}`} workId={content.workId} file={content.file} toolbar={toolbar} /> };
|
|
15
|
+
default: return null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
const titleSuffix = "Talgent";
|
|
4
|
+
|
|
5
|
+
export function DocumentTitle({ title, children }: { title: string; children: ReactNode }) {
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
document.title = `${title} | ${titleSuffix}`;
|
|
8
|
+
}, [title]);
|
|
9
|
+
|
|
10
|
+
return <>{children}</>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** What a page shared by both entries asks of its frame. */
|
|
4
|
+
export type PageLayoutProps = {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
workspaceTitle?: string;
|
|
7
|
+
topActions?: ReactNode;
|
|
8
|
+
centerActions?: ReactNode;
|
|
9
|
+
width?: "contained" | "fluid";
|
|
10
|
+
/** Levels between the entry and the current page, such as Marketplace › Plugins. */
|
|
11
|
+
trail?: Array<{ label: string; onPress: () => void }>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Where an entry serves a person's own pages. */
|
|
15
|
+
export type PersonalPaths = { newWork: string; works: string; work: (workId: string) => string; library: string; usage: string };
|
|
16
|
+
|
|
17
|
+
/** A person's own pages under root; at an entry's root, its New work page is
|
|
18
|
+
* the root itself. */
|
|
19
|
+
export function personalPathsAt(root: "" | `/${string}`): PersonalPaths {
|
|
20
|
+
return {
|
|
21
|
+
newWork: root ? `${root}/new` : "/",
|
|
22
|
+
works: `${root}/works`,
|
|
23
|
+
work: workId => `${root}/works/${encodeURIComponent(workId)}`,
|
|
24
|
+
library: `${root}/library`,
|
|
25
|
+
usage: `${root}/usage`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The entry a shared page renders in: its page frame, the page a signed-in
|
|
30
|
+
* person lands on, and where it serves the person's own pages. */
|
|
31
|
+
export type EntryShell = { Layout: ComponentType<PageLayoutProps>; home: string; personal: PersonalPaths };
|
|
32
|
+
|
|
33
|
+
export const EntryShellContext = createContext<EntryShell | null>(null);
|
|
34
|
+
|
|
35
|
+
export function useEntryShell() {
|
|
36
|
+
const value = useContext(EntryShellContext);
|
|
37
|
+
if (!value) throw new Error("A shared page requires an EntryShellContext");
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Button, Tooltip } from "@heroui/react";
|
|
2
|
+
import { Download } from "lucide-react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import type { PreviewVerdict } from "../preview/preview-verdict";
|
|
5
|
+
import { FilePreview } from "../preview/file-preview";
|
|
6
|
+
|
|
7
|
+
/** One file in the reading area: its preview, and its download in the
|
|
8
|
+
* reading area's top bar. */
|
|
9
|
+
export function FileReading({ name, verdictKey, loadVerdict, requestRender, toolbar, download }: {
|
|
10
|
+
name: string;
|
|
11
|
+
verdictKey: readonly unknown[];
|
|
12
|
+
loadVerdict: (signal: AbortSignal) => Promise<PreviewVerdict>;
|
|
13
|
+
requestRender: () => Promise<PreviewVerdict>;
|
|
14
|
+
toolbar: HTMLElement | null;
|
|
15
|
+
download: { run: () => void; isPending: boolean; error: Error | null; reset: () => void };
|
|
16
|
+
}) {
|
|
17
|
+
return <>
|
|
18
|
+
{toolbar && createPortal(<Tooltip><Button isIconOnly size="sm" variant="ghost" aria-label="Download file" isPending={download.isPending} onPress={download.run}><Download size={16} /></Button><Tooltip.Content>Download</Tooltip.Content></Tooltip>, toolbar)}
|
|
19
|
+
{download.error && <p role="alert">{download.error.message} <Button variant="ghost" size="sm" onPress={download.reset}>Dismiss</Button></p>}
|
|
20
|
+
<FilePreview name={name} toolbar={toolbar} onDownload={download.run} verdictKey={verdictKey} loadVerdict={loadVerdict} requestRender={requestRender} />
|
|
21
|
+
</>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import type { WorkFile } from "@talgent/workbench-contracts/agent/public/v1/work_file_pb";
|
|
3
|
+
import type { ToolPreview } from "../work/work-message-flow";
|
|
4
|
+
|
|
5
|
+
/** What the reading area can show, by kind. `label` names what the reading
|
|
6
|
+
* belongs to, the first crumb of its location. An extension adds its own
|
|
7
|
+
* kinds by augmenting this interface and renders them with its own
|
|
8
|
+
* ReadingRenderer. */
|
|
9
|
+
export interface ReadingKinds {
|
|
10
|
+
tool: { label: string; tool: ToolPreview };
|
|
11
|
+
/** The Work's Token Usage, its state read from the Work itself. */
|
|
12
|
+
trace: { label: string; projectId: string; workId: string; createdAt: Date | null };
|
|
13
|
+
"work-file": { label: string; workId: string; file: WorkFile };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ReadingContent = { [Kind in keyof ReadingKinds]: { kind: Kind } & ReadingKinds[Kind] }[keyof ReadingKinds];
|
|
17
|
+
|
|
18
|
+
export type ReadingSlots = { toolbar: HTMLDivElement | null; location: HTMLDivElement | null };
|
|
19
|
+
|
|
20
|
+
/** How one reading shows: `ownLocation` gives it the location slot to name its
|
|
21
|
+
* own path; otherwise its location is its label and title. */
|
|
22
|
+
export type ReadingView = { key: string; label: string; title: string; ownLocation?: boolean; render: (slots: ReadingSlots) => ReactNode };
|
|
23
|
+
|
|
24
|
+
export type ReadingRenderer = (content: ReadingContent) => ReadingView | null;
|
|
25
|
+
|
|
26
|
+
export const ReadingContext = createContext<{
|
|
27
|
+
content: ReadingContent | null;
|
|
28
|
+
open: (content: ReadingContent, trigger?: HTMLElement) => void;
|
|
29
|
+
close: () => void;
|
|
30
|
+
contextToolbar: HTMLDivElement | null;
|
|
31
|
+
// The Work context card's toggle, always the topbar's last button.
|
|
32
|
+
contextToggleToolbar: HTMLDivElement | null;
|
|
33
|
+
workStatusToolbar: HTMLDivElement | null;
|
|
34
|
+
toolbar: HTMLDivElement | null;
|
|
35
|
+
} | null>(null);
|
|
36
|
+
|
|
37
|
+
export function useReadingArea() {
|
|
38
|
+
const value = useContext(ReadingContext);
|
|
39
|
+
if (!value) throw new Error("Reading area requires AppShell");
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
.p-app[data-reading="true"] { height: 100dvh; overflow: hidden; grid-template-columns: auto minmax(300px,var(--pr-main-share,1fr)) 1px minmax(320px,var(--pr-reader-share,2fr)); }
|
|
2
|
+
.p-app[data-reading="true"]>.p-body { display: flex; flex-direction: column; min-height: 0; overflow: hidden; container-type: inline-size; }
|
|
3
|
+
.p-app[data-reading="true"] .p-topbar { position: relative; flex-shrink: 0; grid-template-columns: minmax(0,1fr) auto minmax(0,1fr); gap: 6px; padding-inline: 10px; }
|
|
4
|
+
.p-app[data-reading="true"] .p-breadcrumb { grid-column: 1; grid-row: 1; flex-wrap: nowrap; overflow: hidden; }
|
|
5
|
+
.p-app[data-reading="true"] .p-location { flex-wrap: nowrap; white-space: nowrap; }
|
|
6
|
+
.p-app[data-reading="true"] .p-perspective-tabs { grid-column: 2; grid-row: 1; justify-self: center; }
|
|
7
|
+
.p-app[data-reading="true"] .p-page-actions { grid-column: 3; grid-row: 1; justify-self: end; flex-wrap: nowrap; gap: 4px; }
|
|
8
|
+
@container (max-width: 560px) {
|
|
9
|
+
.p-app[data-reading="true"] .p-location>li:not(:last-child) { display: none; }
|
|
10
|
+
.p-app[data-reading="true"] .p-perspective-tabs .tabs__tab { padding-inline: 10px; }
|
|
11
|
+
.p-app[data-reading="true"] .p-page-actions .button:has(.lucide-check-check) { font-size: 0; gap: 0; width: 32px; min-width: 32px; padding-inline: 8px; }
|
|
12
|
+
}
|
|
13
|
+
@container (max-width: 380px) {
|
|
14
|
+
.p-app[data-reading="true"] .p-topbar { gap: 4px; padding-inline: 6px; }
|
|
15
|
+
.p-app[data-reading="true"] .p-perspective-tabs .tabs__tab { padding-inline: 6px; font-size: 12px; }
|
|
16
|
+
.p-app[data-reading="true"] .p-page-actions { gap: 2px; }
|
|
17
|
+
.p-app[data-reading="true"] .p-page-actions .button { width: 28px; min-width: 28px; padding-inline: 6px; }
|
|
18
|
+
}
|
|
19
|
+
.p-app[data-reading="true"] .p-page { flex: 1; min-height: 0; overflow: auto; padding: 8px 14px 16px; }
|
|
20
|
+
.p-app[data-reading="true"] .idp-page,.p-app[data-reading="true"] .agent-grid { grid-template-columns: minmax(0,1fr); gap: 0; min-height: 0; }
|
|
21
|
+
.p-app[data-reading="true"] .agent-grid { height: 100%; }
|
|
22
|
+
.p-app[data-reading="true"] .stream-panel { height: 100%; min-height: 0; }
|
|
23
|
+
.p-app[data-reading="true"] .idp-description { padding: 16px; }
|
|
24
|
+
.p-app[data-reading="true"] .idp-heading { padding-inline: 0; }
|
|
25
|
+
.p-app[data-reading="true"] .idp-meta { flex-wrap: wrap; }
|
|
26
|
+
.p-app[data-reading="true"] .idp-activity>.tabs>.tabs__list-container { overflow-x: auto; }
|
|
27
|
+
.pr-reader { display: flex; flex-direction: column; min-width: 0; min-height: 0; height: 100dvh; background: var(--background); color: var(--foreground); }
|
|
28
|
+
.pr-divider { position: relative; z-index: 30; height: 100dvh; background: var(--separator); cursor: col-resize; touch-action: none; }
|
|
29
|
+
.pr-divider::before { content: ""; position: absolute; inset: 0 -5px; }
|
|
30
|
+
.pr-divider::after { content: ""; position: absolute; width: 3px; inset-block: 0; left: -1px; background: var(--muted); opacity: 0; pointer-events: none; }
|
|
31
|
+
.pr-divider:hover::after,.pr-divider:focus-visible::after,.pr-divider[data-dragging="true"]::after { opacity: 1; }
|
|
32
|
+
.pr-divider:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
33
|
+
.p-app:has(>.pr-divider[data-dragging="true"]) { cursor: col-resize; user-select: none; }
|
|
34
|
+
.p-app:has(>.pr-divider[data-dragging="true"]) iframe { pointer-events: none; }
|
|
35
|
+
.p-app:has(>.pr-reader[data-fullscreen="true"])>.pr-divider { visibility: hidden; }
|
|
36
|
+
.pr-reader[data-fullscreen="true"] { position: fixed; inset: 0; z-index: 60; border: 0; }
|
|
37
|
+
/* The page top bar's frame, minus the page's centred tab column. */
|
|
38
|
+
.p-app .pr-reader>.p-topbar.pr-topbar { position: relative; flex-shrink: 0; grid-template-columns: minmax(0,1fr) auto; }
|
|
39
|
+
.pr-topbar .p-breadcrumb { flex-wrap: nowrap; overflow: hidden; }
|
|
40
|
+
.pr-topbar .p-location { flex-wrap: nowrap; white-space: nowrap; min-width: 0; }
|
|
41
|
+
.pr-topbar .p-location span[title] { display: block; overflow: hidden; text-overflow: ellipsis; }
|
|
42
|
+
.pr-tools { flex-wrap: nowrap; flex-shrink: 0; gap: 4px; }
|
|
43
|
+
.pr-scroll { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 24px; }
|
|
44
|
+
.pr-document,.pr-file-document { width: 100%; margin-inline: auto; }
|
|
45
|
+
/* Total is the headline; the four categories and Cache hit rate read smaller. */
|
|
46
|
+
.pr-metrics { display: grid; grid-template-columns: 1.4fr repeat(5,minmax(0,1fr)); align-items: end; gap: 16px; padding-bottom: 24px; border-bottom: 1px solid var(--separator); }
|
|
47
|
+
.pr-metrics dt { font-size: 12px; color: var(--muted); }
|
|
48
|
+
.pr-metrics dd { margin-top: 8px; font-size: 16px; font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
49
|
+
.pr-metrics>div:first-child dd { font-size: 30px; font-weight: 600; line-height: 1.1; }
|
|
50
|
+
|
|
51
|
+
.pr-section-title { margin-top: 28px; font-size: 14px; font-weight: 600; }
|
|
52
|
+
.pr-file-document>pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; line-height: 1.7; }
|
|
53
|
+
.pr-file-document>img { max-width: 100%; height: auto; margin-inline: auto; }
|
|
54
|
+
.pr-file-document>iframe { width: 100%; height: calc(100dvh - 104px); border: 0; }
|
|
55
|
+
@media (max-width: 1050px) {
|
|
56
|
+
.pr-scroll { padding: 20px; }
|
|
57
|
+
}
|
|
58
|
+
@media (max-width: 740px) {
|
|
59
|
+
.p-app[data-reading="true"] { display: block; }
|
|
60
|
+
.pr-reader { position: fixed; inset: 0; z-index: 60; }
|
|
61
|
+
.pr-divider { display: none; }
|
|
62
|
+
.pr-metrics { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.p-context-actions { display: flex; align-items: center; }
|
|
66
|
+
.p-context-actions:empty { display: none; }
|
|
67
|
+
.pr-location-slot { display: contents; }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Breadcrumbs, Button, Tooltip } from "@heroui/react";
|
|
3
|
+
import { Maximize2, Minimize2, X } from "lucide-react";
|
|
4
|
+
import type { ReadingView } from "./reading-context";
|
|
5
|
+
import "./reading-panel.css";
|
|
6
|
+
|
|
7
|
+
/** The reading area's frame: the divider that splits it from the page, its
|
|
8
|
+
* top bar and its scrolling body. What it shows comes from `view`. */
|
|
9
|
+
export function ReadingPanel({ view, onClose, fullscreen, onFullscreenChange, onSplitChange, split, body, container }: {
|
|
10
|
+
view: ReadingView; onClose: () => void; fullscreen: boolean;
|
|
11
|
+
onFullscreenChange: (value: boolean) => void; onSplitChange: (value: number) => void;
|
|
12
|
+
split: number; body: HTMLElement | null; container: HTMLElement | null;
|
|
13
|
+
}) {
|
|
14
|
+
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
|
|
15
|
+
const closeButton = useRef<HTMLButtonElement>(null);
|
|
16
|
+
const panel = useRef<HTMLElement>(null);
|
|
17
|
+
const [dragging, setDragging] = useState(false);
|
|
18
|
+
const [limits, setLimits] = useState({ min: 0.2, max: 0.7 });
|
|
19
|
+
const { title } = view;
|
|
20
|
+
const [location, setLocation] = useState<HTMLDivElement | null>(null);
|
|
21
|
+
useEffect(() => { closeButton.current?.focus(); }, []);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const escape = (event: KeyboardEvent) => {
|
|
24
|
+
if (event.key !== "Escape" || event.defaultPrevented || (event.target instanceof Element && event.target.closest('[role="dialog"], [role="listbox"]'))) return;
|
|
25
|
+
event.preventDefault();
|
|
26
|
+
if (fullscreen) onFullscreenChange(false); else onClose();
|
|
27
|
+
};
|
|
28
|
+
document.addEventListener("keydown", escape);
|
|
29
|
+
return () => document.removeEventListener("keydown", escape);
|
|
30
|
+
}, [fullscreen, onFullscreenChange, onClose]);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!body || !container) return;
|
|
33
|
+
const measure = () => {
|
|
34
|
+
const width = container.getBoundingClientRect().right - body.getBoundingClientRect().left - 1;
|
|
35
|
+
if (width < 620) return;
|
|
36
|
+
const min = 300 / width, max = 1 - 320 / width;
|
|
37
|
+
setLimits({ min, max });
|
|
38
|
+
onSplitChange(Math.min(max, Math.max(min, split)));
|
|
39
|
+
};
|
|
40
|
+
measure();
|
|
41
|
+
const observer = new ResizeObserver(measure);
|
|
42
|
+
observer.observe(container);
|
|
43
|
+
observer.observe(body);
|
|
44
|
+
return () => observer.disconnect();
|
|
45
|
+
}, [body, container, onSplitChange, split]);
|
|
46
|
+
const resize = (value: number) => onSplitChange(Math.min(limits.max, Math.max(limits.min, value)));
|
|
47
|
+
return <>
|
|
48
|
+
<div className="pr-divider" role="separator" tabIndex={0} aria-label="Resize conversation and reading area" aria-orientation="vertical" aria-valuemin={Math.round(limits.min * 100)} aria-valuemax={Math.round(limits.max * 100)} aria-valuenow={Math.round(split * 100)} aria-valuetext={`Conversation ${Math.round(split * 100)}%, reading area ${Math.round((1 - split) * 100)}%`} data-dragging={dragging}
|
|
49
|
+
onPointerDown={event => { if (event.button !== 0) return; event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); }}
|
|
50
|
+
onPointerMove={event => { if (!event.currentTarget.hasPointerCapture(event.pointerId) || !body || !container) return; const left = body.getBoundingClientRect().left; resize((event.clientX - left) / (container.getBoundingClientRect().right - left - 1)); }}
|
|
51
|
+
onPointerUp={event => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); setDragging(false); }}
|
|
52
|
+
onLostPointerCapture={() => setDragging(false)} onDoubleClick={() => resize(1 / 3)}
|
|
53
|
+
onKeyDown={event => { if (!["ArrowLeft", "ArrowRight", "Home", "End", "Enter"].includes(event.key)) return; event.preventDefault(); resize(event.key === "Home" ? limits.min : event.key === "End" ? limits.max : event.key === "Enter" ? 1 / 3 : split + (event.key === "ArrowLeft" ? -0.02 : 0.02)); }} />
|
|
54
|
+
<section ref={panel} className="pr-reader" data-fullscreen={fullscreen} aria-label={`${title} reading area`}>
|
|
55
|
+
{/* The reading area's top bar is the page top bar: same frame, same
|
|
56
|
+
breadcrumbs, same action row. */}
|
|
57
|
+
<header className="p-topbar pr-topbar">
|
|
58
|
+
<div className="p-breadcrumb">
|
|
59
|
+
{/* A reading with a path of its own names it in the same breadcrumbs. */}
|
|
60
|
+
{view.ownLocation ? <div ref={setLocation} className="pr-location-slot" />
|
|
61
|
+
: <Breadcrumbs aria-label="Reading location" className="p-location">
|
|
62
|
+
<Breadcrumbs.Item>{view.label}</Breadcrumbs.Item>
|
|
63
|
+
<Breadcrumbs.Item><span title={title}>{title}</span></Breadcrumbs.Item>
|
|
64
|
+
</Breadcrumbs>}
|
|
65
|
+
</div>
|
|
66
|
+
<div className="p-actions pr-tools"><div ref={setToolbar} className="p-context-actions" />
|
|
67
|
+
<Tooltip><Button isIconOnly size="sm" variant="ghost" aria-label={fullscreen ? "Exit full screen" : "Full screen"} onPress={() => onFullscreenChange(!fullscreen)}>{fullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</Button><Tooltip.Content>{fullscreen ? "Exit full screen" : "Full screen"}</Tooltip.Content></Tooltip>
|
|
68
|
+
<Tooltip><Button ref={closeButton} isIconOnly size="sm" variant="ghost" aria-label="Close reading area" onPress={onClose}><X size={18} /></Button><Tooltip.Content>Close</Tooltip.Content></Tooltip>
|
|
69
|
+
</div>
|
|
70
|
+
</header>
|
|
71
|
+
<div className="pr-scroll">{view.render({ toolbar, location })}</div>
|
|
72
|
+
</section>
|
|
73
|
+
</>;
|
|
74
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Button, Link as HeroLink, Tooltip, buttonVariants, typographyVariants } from "@heroui/react";
|
|
3
|
+
import { PanelLeftClose, PanelLeftOpen, X, type LucideIcon } from "lucide-react";
|
|
4
|
+
import { Link } from "react-router-dom";
|
|
5
|
+
|
|
6
|
+
/** One entry of a sidebar's navigation. */
|
|
7
|
+
export type SidebarItem = { label: string; icon: LucideIcon; to: string; badge?: ReactNode; describedBy?: string };
|
|
8
|
+
|
|
9
|
+
/** A sidebar's navigation link: icon and label, or the icon with a tooltip when compact. */
|
|
10
|
+
export function SidebarLink({ item, current, compact, onCloseMobile }: { item: SidebarItem; current: boolean; compact: boolean; onCloseMobile: () => void }) {
|
|
11
|
+
return <Tooltip isDisabled={!compact}>
|
|
12
|
+
<HeroLink href={item.to} aria-label={item.label} aria-describedby={item.describedBy} aria-current={current ? "page" : undefined}
|
|
13
|
+
className={buttonVariants({ size: "sm", variant: current ? "secondary" : "ghost", isIconOnly: compact, className: compact ? "relative" : "p-nav-item" })}
|
|
14
|
+
render={props => "href" in props ? <Link {...props} to={item.to} onClick={onCloseMobile} /> : <span {...props} />}>
|
|
15
|
+
<item.icon size={17} aria-hidden="true" />
|
|
16
|
+
{!compact && <span className={`${typographyVariants({ type: "body-sm", color: current ? "default" : "muted" }).base()} p-nav-label`}>{item.label}</span>}
|
|
17
|
+
{item.badge}
|
|
18
|
+
</HeroLink>
|
|
19
|
+
<Tooltip.Content placement="right">{item.label}</Tooltip.Content>
|
|
20
|
+
</Tooltip>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A sidebar's top: the brand leading to the entry's home, and collapsing,
|
|
24
|
+
* expanding or closing the navigation. */
|
|
25
|
+
export function SidebarHeader({ compact, mobile, isReading, homeLabel, onHome, onCollapsedChange, onCloseMobile }: {
|
|
26
|
+
compact: boolean; mobile: boolean; isReading: boolean; homeLabel: string; onHome: () => void; onCollapsedChange: (collapsed: boolean) => void; onCloseMobile: () => void;
|
|
27
|
+
}) {
|
|
28
|
+
return <div className="p-sidebar-header">
|
|
29
|
+
<div className="p-sidebar-brand" data-testid="sidebar-brand-row">
|
|
30
|
+
<div className="p-brand-lockup">
|
|
31
|
+
<Button size="sm" variant="ghost" className="p-brand-button" isIconOnly aria-label={homeLabel} onPress={onHome}><span className="p-brand-icon" aria-hidden="true" /></Button>
|
|
32
|
+
{!compact && <span className="p-brand-name" aria-hidden="true" />}
|
|
33
|
+
</div>
|
|
34
|
+
{!compact && <Tooltip><Button variant="ghost" size="sm" isIconOnly aria-label={mobile ? "Close navigation" : "Collapse sidebar"} onPress={mobile ? onCloseMobile : () => onCollapsedChange(true)}>{mobile ? <X size={16} /> : <PanelLeftClose size={16} />}</Button><Tooltip.Content>{mobile ? "Close navigation" : "Collapse sidebar"}</Tooltip.Content></Tooltip>}
|
|
35
|
+
</div>
|
|
36
|
+
{compact && <Tooltip><Button variant="ghost" isIconOnly isDisabled={isReading} aria-label="Expand sidebar" onPress={() => onCollapsedChange(false)}><PanelLeftOpen size={18} /></Button><Tooltip.Content placement="right">Expand sidebar</Tooltip.Content></Tooltip>}
|
|
37
|
+
</div>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* A page's context card. */
|
|
2
|
+
/* The rail's top edge on both tabs: below the top bar and the page's 16px top
|
|
3
|
+
padding, where the Work grid starts. */
|
|
4
|
+
.p-page { --context-rail-width: 280px; --context-gap: 40px; --context-content-gutter: 28px; --context-rail-top: calc(var(--topbar-height) + 16px); }
|
|
5
|
+
.p-page>.idp-page,
|
|
6
|
+
.p-page>.agent-grid { grid-template-columns: minmax(0, 1fr) var(--context-rail-width); gap: var(--context-gap); }
|
|
7
|
+
.p-page>.agent-grid:has(.stream-panel-unstarted, .stream-panel-starting) { grid-template-columns: minmax(0, 1fr); }
|
|
8
|
+
.cr-rail { position: sticky; top: var(--context-rail-top); align-self: start; min-width: 0; margin-top: -8px; }
|
|
9
|
+
.cr-file-row { display: flex; align-items: center; min-width: 0; }
|
|
10
|
+
.cr-file { display: flex; justify-content: start; gap: 8px; width: 100%; min-width: 0; height: 30px; padding: 4px 0; font-size: 13px; font-weight: 400; text-align: left; }
|
|
11
|
+
.cr-file>svg { flex-shrink: 0; }
|
|
12
|
+
.cr-file>span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
13
|
+
.cr-file-icon[data-kind="document"] { color: color-mix(in oklab,var(--color-blue-500) 65%,var(--foreground)); }
|
|
14
|
+
.cr-file-icon[data-kind="pdf"] { color: var(--danger); }
|
|
15
|
+
.cr-file-icon[data-kind="image"] { color: color-mix(in oklab,var(--color-pink-500) 65%,var(--foreground)); }
|
|
16
|
+
.cr-file-icon[data-kind="code"] { color: var(--muted); }
|
|
17
|
+
.cr-file-more { min-width: 22px; width: 22px; height: 28px; padding: 0; color: var(--muted); opacity: 0; }
|
|
18
|
+
.cr-file-row:hover .cr-file-more,.cr-file-row:focus-within .cr-file-more { opacity: 1; }
|
|
19
|
+
.cr-todos { display: grid; gap: 2px; margin: 0; padding: 8px 0 0; list-style: none; font-size: 13px; line-height: 26px; }
|
|
20
|
+
.cr-todos>li { display: flex; gap: 10px; align-items: center; min-width: 0; color: var(--foreground); }
|
|
21
|
+
.cr-todos>li>span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
22
|
+
.cr-todos svg, .cr-todo-now { flex-shrink: 0; }
|
|
23
|
+
.cr-todos [data-status="completed"] { color: var(--muted); }
|
|
24
|
+
.cr-todos [data-status="pending"]>svg { color: color-mix(in oklch,var(--muted) 55%,transparent); }
|
|
25
|
+
.cr-todos [data-status="in_progress"] { font-weight: 500; }
|
|
26
|
+
/* The item being worked on: the one mark in the list that moves. */
|
|
27
|
+
.cr-todo-now { position: relative; width: 12px; height: 12px; }
|
|
28
|
+
.cr-todo-now::after { content: ""; position: absolute; inset: 2px; border-radius: 50%; background: var(--foreground); animation: cr-todo-pulse 1.8s ease-in-out infinite; }
|
|
29
|
+
@keyframes cr-todo-pulse { 50% { opacity: .25; transform: scale(.6); } }
|
|
30
|
+
@media (prefers-reduced-motion: reduce) { .cr-todo-now::after { animation: none; } }
|
|
31
|
+
.cr-popover { width: 300px; max-width: calc(100vw - 24px); }
|
|
32
|
+
.cr-popover .popover__dialog { display: grid; gap: 12px; }
|
|
33
|
+
.cr-manifest { max-height: 240px; overflow: auto; font-size: 12px; overflow-wrap: anywhere; }
|
|
34
|
+
.cr-token-usage { padding-top: 0; font-variant-numeric: tabular-nums; }
|
|
35
|
+
.cr-token-bar { display: flex; gap: 3px; height: 5px; margin: 16px 0 12px; overflow: hidden; border-radius: 3px; }
|
|
36
|
+
.cr-token-bar>span { min-width: 0; }
|
|
37
|
+
@media (pointer: coarse) { .cr-file-more { opacity: 1; } }
|
|
38
|
+
@media (max-width: 1100px) {
|
|
39
|
+
.p-page { --context-rail-width: 250px; --context-gap: 20px; }
|
|
40
|
+
}
|
|
41
|
+
@media (max-width: 900px) {
|
|
42
|
+
.p-page>.idp-page,.p-page>.agent-grid { grid-template-columns: minmax(0,1fr); }
|
|
43
|
+
.cr-rail { position: relative; top: auto; margin-top: 30px; width: 100%; }
|
|
44
|
+
.cr-file-more { opacity: 1; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.cr-rail[data-collapsed="true"] { display: none; }
|
|
48
|
+
.cr-file-tooltip { display: grid; gap: 5px; max-width: min(320px,calc(100vw - 24px)); padding: 12px; font-size: 12px; overflow-wrap: anywhere; }
|
|
49
|
+
.cr-file-tooltip strong { font-size: 13px; font-weight: 500; }
|
|
50
|
+
.cr-file-tooltip>span { color: var(--muted); }
|
|
51
|
+
|
|
52
|
+
/* Token Usage is one total; its bar and parts show on hover or keyboard
|
|
53
|
+
focus within the section, and always where there is no hover. */
|
|
54
|
+
.cr-token-total { display: block; margin-top: 4px; font-size: 18px; line-height: 1.3; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.3px; }
|
|
55
|
+
/* The parts stay readable by assistive tech while visually folded away. */
|
|
56
|
+
@media (hover: hover) {
|
|
57
|
+
.cr-token-section:not(:hover, :focus-within) .cr-token-bar { display: none; }
|
|
58
|
+
.cr-token-section:not(:hover, :focus-within) .cr-token-breakdown { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@media (max-width: 800px) { .p-page { --context-content-gutter: 18px; } }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/* One quiet line where a private conversation would be; status and Progress
|
|
2
|
+
stay in the context card. */
|
|
3
|
+
.wca-unavailable { display: flex; align-items: center; justify-content: center; gap: 10px; margin: 0; padding: 30vh 16px 0; color: var(--foreground); font-size: 15px; text-align: center; text-wrap: balance; }
|
|
4
|
+
/* The line is the panel's message, so it reads in the text color; --muted
|
|
5
|
+
measures 4.44:1 on the page background. The icon stays quiet. */
|
|
6
|
+
.wca-unavailable svg { flex-shrink: 0; color: var(--muted); }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Lock, ShieldOff } from "lucide-react";
|
|
2
|
+
import "./conversation-unavailable.css";
|
|
3
|
+
|
|
4
|
+
/** The single line shown in place of a conversation the caller cannot read. */
|
|
5
|
+
export function ConversationUnavailable({ withdrawn = false }: { withdrawn?: boolean }) {
|
|
6
|
+
const Icon = withdrawn ? ShieldOff : Lock;
|
|
7
|
+
return <p className="wca-unavailable" role="status">
|
|
8
|
+
<Icon size={20} aria-hidden="true" />
|
|
9
|
+
{withdrawn ? "Your access to this conversation was withdrawn" : "This conversation is private"}
|
|
10
|
+
</p>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Tooltip } from "@heroui/react";
|
|
2
|
+
import { CircleCheck, CircleSlash, CircleX, LoaderCircle } from "lucide-react";
|
|
3
|
+
import { AgentIcon } from "../agent/agent-icon";
|
|
4
|
+
import { agentColors } from "../../lib/agent-icon";
|
|
5
|
+
import type { SubAgent, SubAgentStatus } from "../../lib/subagents";
|
|
6
|
+
|
|
7
|
+
const statuses: Record<SubAgentStatus, { label: string; Icon: typeof LoaderCircle; tone: "success" | "danger" | "muted" }> = {
|
|
8
|
+
running: { label: "Running", Icon: LoaderCircle, tone: "success" },
|
|
9
|
+
completed: { label: "Completed", Icon: CircleCheck, tone: "muted" },
|
|
10
|
+
failed: { label: "Failed", Icon: CircleX, tone: "danger" },
|
|
11
|
+
stopped: { label: "Stopped", Icon: CircleSlash, tone: "muted" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// A SubAgent keeps one look for its whole life: its color follows its Harness
|
|
15
|
+
// identity, its glyph its type, with the robot for every other type.
|
|
16
|
+
const colorKeys = Object.keys(agentColors);
|
|
17
|
+
const typeGlyphs: Record<string, string> = { Explore: "compass", Plan: "brain", fork: "atom" };
|
|
18
|
+
const hash = (text: string) => [...text].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 7);
|
|
19
|
+
const iconOf = (agent: SubAgent) => ({ glyph: typeGlyphs[agent.type] ?? "bot", color: colorKeys[hash(agent.id) % colorKeys.length] });
|
|
20
|
+
const typeOf = (agent: SubAgent) => agent.type || "SubAgent";
|
|
21
|
+
|
|
22
|
+
// Status is carried by weight: running is full color with the orbit, an ended
|
|
23
|
+
// one recedes, and only failed and stopped carry a mark.
|
|
24
|
+
export function SubAgentItem({ agent, leaving }: { agent: SubAgent; leaving: boolean }) {
|
|
25
|
+
const status = statuses[agent.status];
|
|
26
|
+
const marked = agent.status === "failed" || agent.status === "stopped";
|
|
27
|
+
return <li data-leaving={leaving || undefined}>
|
|
28
|
+
<Tooltip delay={200} closeDelay={0}>
|
|
29
|
+
<Tooltip.Trigger className="sa-trigger" tabIndex={0} aria-label={`${typeOf(agent)}, ${status.label}${agent.task ? `: ${agent.task}` : ""}`}>
|
|
30
|
+
<span className="sa-avatar" data-status={agent.status}>
|
|
31
|
+
<AgentIcon icon={iconOf(agent)} size={26} />
|
|
32
|
+
{marked && <span className="sa-badge" data-tone={status.tone} aria-hidden="true"><status.Icon size={10} strokeWidth={2} /></span>}
|
|
33
|
+
</span>
|
|
34
|
+
</Tooltip.Trigger>
|
|
35
|
+
<Tooltip.Content className="sa-tip">
|
|
36
|
+
<span className="sa-tip-head"><AgentIcon icon={iconOf(agent)} size={20} /><strong>{typeOf(agent)}</strong></span>
|
|
37
|
+
<span className="sa-tip-status" data-tone={status.tone}><status.Icon size={12} aria-hidden="true" />{status.label}</span>
|
|
38
|
+
{agent.task && <p>{agent.task}</p>}
|
|
39
|
+
</Tooltip.Content>
|
|
40
|
+
</Tooltip>
|
|
41
|
+
</li>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* The Work context card's Agents section. */
|
|
2
|
+
.sa-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 10px 0 2px; list-style: none; }
|
|
3
|
+
.sa-row>li { display: flex; animation: sa-in 200ms cubic-bezier(0.2, 0, 0, 1); }
|
|
4
|
+
.sa-row>li[data-leaving] { animation: sa-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards; }
|
|
5
|
+
@keyframes sa-in { from { opacity: 0; transform: scale(.6); } }
|
|
6
|
+
@keyframes sa-out { to { opacity: 0; transform: scale(.6); } }
|
|
7
|
+
|
|
8
|
+
.sa-trigger { display: inline-flex; padding: 3px; border-radius: 10px; cursor: default; }
|
|
9
|
+
.sa-trigger:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
10
|
+
.sa-avatar { position: relative; display: inline-flex; }
|
|
11
|
+
.sa-avatar .agent-icon { transition: opacity 200ms, filter 200ms; }
|
|
12
|
+
/* The one moving mark: an orbit while a SubAgent runs. */
|
|
13
|
+
.sa-avatar[data-status="running"]::after { content: ""; position: absolute; inset: -3px; border: 1.5px solid transparent; border-top-color: var(--success); border-inline-end-color: var(--success); border-radius: 10px; pointer-events: none; animation: sa-orbit 1.8s linear infinite; }
|
|
14
|
+
@keyframes sa-orbit { to { transform: rotate(360deg); } }
|
|
15
|
+
.sa-avatar[data-status="completed"] .agent-icon,
|
|
16
|
+
.sa-avatar[data-status="stopped"] .agent-icon { opacity: .42; filter: grayscale(.6); }
|
|
17
|
+
.sa-trigger:hover .agent-icon, .sa-trigger:focus-visible .agent-icon { opacity: 1; filter: none; }
|
|
18
|
+
.sa-badge { position: absolute; right: -4px; bottom: -4px; z-index: 1; display: inline-grid; place-items: center; padding: 2px; border-radius: 999px; background: var(--surface); }
|
|
19
|
+
.sa-badge[data-tone="danger"], .sa-tip-status[data-tone="danger"] { color: var(--danger); }
|
|
20
|
+
.sa-badge[data-tone="muted"] { color: var(--muted); }
|
|
21
|
+
|
|
22
|
+
.sa-tip { display: grid; gap: 6px; max-width: 260px; }
|
|
23
|
+
.sa-tip-head { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
|
24
|
+
.sa-tip-status { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); }
|
|
25
|
+
.sa-tip-status[data-tone="success"] { color: var(--success); }
|
|
26
|
+
.sa-tip p { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--foreground); word-break: normal; overflow-wrap: break-word; }
|
|
27
|
+
|
|
28
|
+
@media (prefers-reduced-motion: reduce) {
|
|
29
|
+
.sa-row>li, .sa-avatar[data-status="running"]::after { animation: none; }
|
|
30
|
+
.sa-row>li[data-leaving] { animation: none; visibility: hidden; }
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { SubAgent } from "../../lib/subagents";
|
|
3
|
+
import { RailSection } from "../rail/rail-section";
|
|
4
|
+
import { SubAgentItem } from "./subagent-item";
|
|
5
|
+
import "./subagents-section.css";
|
|
6
|
+
|
|
7
|
+
const leaveMs = 200;
|
|
8
|
+
|
|
9
|
+
// SubAgents arrive only on the Work's stream, so a reader who cannot read the
|
|
10
|
+
// conversation has none. Undefined is no report yet; the first report is
|
|
11
|
+
// where changes count from. The section opens when a SubAgent starts running
|
|
12
|
+
// while the rail is in view, as Progress opens on a new report; one finishing
|
|
13
|
+
// never opens it.
|
|
14
|
+
export function useSubAgentsSection({ agents, scope, visible }: { agents: SubAgent[] | undefined; scope: string; visible: boolean }) {
|
|
15
|
+
const running = agents?.filter(agent => agent.status === "running").map(agent => agent.id);
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
const [seen, setSeen] = useState({ scope, running });
|
|
18
|
+
if (seen.scope !== scope) {
|
|
19
|
+
setSeen({ scope, running });
|
|
20
|
+
setOpen(false);
|
|
21
|
+
} else if (running && running.join() !== seen.running?.join()) {
|
|
22
|
+
setSeen({ scope, running });
|
|
23
|
+
if (seen.running && visible && running.some(id => !seen.running!.includes(id))) setOpen(true);
|
|
24
|
+
}
|
|
25
|
+
// Cleared SubAgents leave with their exit motion before the section goes.
|
|
26
|
+
const list = agents ?? [];
|
|
27
|
+
const [shown, setShown] = useState(list);
|
|
28
|
+
const leaving = !list.length && shown.length > 0;
|
|
29
|
+
if (list.length && shown !== list) setShown(list);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!leaving) return;
|
|
32
|
+
const timer = window.setTimeout(() => setShown([]), leaveMs);
|
|
33
|
+
return () => window.clearTimeout(timer);
|
|
34
|
+
}, [leaving]);
|
|
35
|
+
if (!shown.length) return null;
|
|
36
|
+
const runningCount = running?.length ?? 0;
|
|
37
|
+
return <RailSection title="Agents" summary={leaving ? undefined : runningCount ? `${runningCount} running` : `${shown.length}`} open={open} onOpenChange={setOpen}>
|
|
38
|
+
<ul className="sa-row" aria-label="SubAgents">{shown.map(agent => <SubAgentItem key={agent.id} agent={agent} leaving={leaving} />)}</ul>
|
|
39
|
+
</RailSection>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Button, Tooltip } from "@heroui/react";
|
|
2
|
+
import { ChartNoAxesColumnIncreasing } from "lucide-react";
|
|
3
|
+
import { formatCompactTokenCount } from "../../lib/trace-view-model";
|
|
4
|
+
import { tokenCategories, tokenTotal, tokenUsageFromMeasurements } from "../../lib/token-usage";
|
|
5
|
+
import { TokenBreakdown } from "../analytics/token-breakdown";
|
|
6
|
+
import { RailSection } from "../rail/rail-section";
|
|
7
|
+
|
|
8
|
+
type Totals = Array<{ metricKind: string; value: number }>;
|
|
9
|
+
|
|
10
|
+
/** The Work context card's Token Usage section, whose action opens the Work's
|
|
11
|
+
* Token Usage reader. */
|
|
12
|
+
export function WorkTokenSection({ totals, onOpenTimeline }: { totals: Totals; onOpenTimeline: () => void }) {
|
|
13
|
+
return <RailSection title="Token Usage" className="cr-token-section" action={<Tooltip><Button isIconOnly size="sm" variant="ghost" aria-label="View Work timeline" onPress={onOpenTimeline}><ChartNoAxesColumnIncreasing size={15} aria-hidden="true" /></Button><Tooltip.Content>View Work timeline</Tooltip.Content></Tooltip>}>
|
|
14
|
+
<TokenUsage totals={totals} />
|
|
15
|
+
</RailSection>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A Work's token usage: the total, then on hover or focus a bar of its four
|
|
19
|
+
* parts, their amounts and the Cache hit rate. */
|
|
20
|
+
function TokenUsage({ totals: measurements }: { totals: Totals }) {
|
|
21
|
+
const usage = tokenUsageFromMeasurements(measurements);
|
|
22
|
+
const total = tokenTotal(usage);
|
|
23
|
+
return <div className="cr-token-usage">
|
|
24
|
+
<strong className="cr-token-total">{formatCompactTokenCount(total)}</strong>
|
|
25
|
+
{total > 0 && <div className="cr-token-bar" aria-hidden="true">{tokenCategories.map(({ key }) => <span key={key} data-token-tone={key} style={{ flexGrow: usage[key] / total }} />)}</div>}
|
|
26
|
+
<TokenBreakdown usage={usage} className="cr-token-breakdown" />
|
|
27
|
+
</div>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { Alert, Button } from "@heroui/react";
|
|
4
|
+
import { MessageDetailFormat } from "@talgent/workbench-contracts/dispatcher/public/v1/unified_message_pb";
|
|
5
|
+
import type { GetMessageDetailResponse } from "@talgent/workbench-contracts/streamgateway/public/v1/streamgateway_pb";
|
|
6
|
+
import { useWorkStreamSource } from "../../lib/work-stream-source";
|
|
7
|
+
|
|
8
|
+
export const detailPageSize = 16384;
|
|
9
|
+
|
|
10
|
+
// Mount only while the detail is open. Each reader retains one bounded page;
|
|
11
|
+
// closing it aborts the request and releases the query's original body.
|
|
12
|
+
export function ToolMessageDetail({ workId, messageId }: { workId: string; messageId: string }) {
|
|
13
|
+
const streamClient = useWorkStreamSource();
|
|
14
|
+
const [position, setPosition] = useState({ workId, messageId, offset: 0 });
|
|
15
|
+
const offset = position.workId === workId && position.messageId === messageId ? position.offset : 0;
|
|
16
|
+
const detail = useQuery({
|
|
17
|
+
queryKey: ["message-detail", workId, messageId, offset],
|
|
18
|
+
queryFn: ({ signal }) => streamClient.getMessageDetail({ agentWorkId: workId, messageId, offset, limit: detailPageSize }, { signal }),
|
|
19
|
+
gcTime: 0, staleTime: Infinity, retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false,
|
|
20
|
+
});
|
|
21
|
+
return <div className="wm-tool-detail">
|
|
22
|
+
{detail.isPending && <p className="sr-only" role="status">Loading detail…</p>}
|
|
23
|
+
{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>}
|
|
24
|
+
{detail.data && <MessageDetailContent detail={detail.data} offset={offset} onPage={offset => setPosition({ workId, messageId, offset })} />}
|
|
25
|
+
</div>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function MessageDetailContent({ detail, offset, onPage, emptyLabel = "Empty output" }: { emptyLabel?: string; detail: GetMessageDetailResponse; offset: number; onPage: (offset: number) => void }) {
|
|
29
|
+
return <>
|
|
30
|
+
{detail.format === MessageDetailFormat.UNSPECIFIED ? <p>No detail available</p>
|
|
31
|
+
: detail.totalLength === 0 ? <p>{emptyLabel}</p>
|
|
32
|
+
: <pre tabIndex={0} aria-label="Original tool detail">{detail.text}</pre>}
|
|
33
|
+
{detail.totalLength > detailPageSize && <nav aria-label="Detail pages" className="wm-detail-pages">
|
|
34
|
+
<Button variant="ghost" size="sm" isDisabled={offset === 0} onPress={() => onPage(Math.max(0, offset - detailPageSize))}>Previous page</Button>
|
|
35
|
+
<span>{Math.floor(offset / detailPageSize) + 1} / {Math.ceil(detail.totalLength / detailPageSize)}</span>
|
|
36
|
+
<Button variant="ghost" size="sm" isDisabled={detail.nextOffset >= detail.totalLength} onPress={() => onPage(detail.nextOffset)}>Next page</Button>
|
|
37
|
+
</nav>}
|
|
38
|
+
</>;
|
|
39
|
+
}
|