@talgent/workbench-ui 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +87 -3
- 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,88 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
4
|
+
import { Alert, Button, Label, ListBox, SearchField, Select, Table } from "@heroui/react";
|
|
5
|
+
import { WorkFileKind, WorkFileSchema } from "@talgent/workbench-contracts/agent/public/v1/work_file_pb";
|
|
6
|
+
import type { LibraryFile, LibraryWork } from "@talgent/workbench-contracts/agent/public/v1/personal_work_pb";
|
|
7
|
+
import { FileText } from "lucide-react";
|
|
8
|
+
import { Link } from "react-router-dom";
|
|
9
|
+
import { formatAgo } from "../../components/analytics/work-usage/work-status";
|
|
10
|
+
import { formatBytes } from "../../components/preview/preview-verdict";
|
|
11
|
+
import { PageLayout } from "../../components/shell/page-layout";
|
|
12
|
+
import { useEntryShell } from "../../components/shell/entry-shell";
|
|
13
|
+
import { useReadingArea } from "../../components/shell/reading-context";
|
|
14
|
+
import { useDebounced } from "../../hooks/use-debounced";
|
|
15
|
+
import { useNow } from "../../lib/use-now";
|
|
16
|
+
import { untitledWorkLabel, useDirectoryUsers, useLibrary } from "../../lib/personal-work-service";
|
|
17
|
+
import { Person } from "./person";
|
|
18
|
+
|
|
19
|
+
const periods = [{ id: "any", label: "Any time", days: null }, { id: "1", label: "Last 24 hours", days: 1 }, { id: "7", label: "Last 7 days", days: 7 }, { id: "30", label: "Last 30 days", days: 30 }] as const;
|
|
20
|
+
|
|
21
|
+
/** A Work's name in the Library. */
|
|
22
|
+
function libraryWorkTitle(work: Pick<LibraryWork, "title"> | undefined) {
|
|
23
|
+
return work?.title || untitledWorkLabel;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const rowKey = (file: LibraryFile) => `${file.workId}/${file.name}`;
|
|
27
|
+
|
|
28
|
+
/** Library: the Artifacts of the person's Personal Works and of the Works
|
|
29
|
+
* shared with them, searched by name and narrowed by Work and time. */
|
|
30
|
+
export function LibraryPage() {
|
|
31
|
+
return <PageLayout workspaceTitle="Library"><LibraryTable /></PageLayout>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function LibraryTable() {
|
|
35
|
+
const { personal } = useEntryShell();
|
|
36
|
+
const reader = useReadingArea();
|
|
37
|
+
const [name, setName] = useState("");
|
|
38
|
+
const [workId, setWorkId] = useState("");
|
|
39
|
+
const [period, setPeriod] = useState<(typeof periods)[number]["id"]>("any");
|
|
40
|
+
const query = useDebounced(name, 250);
|
|
41
|
+
const library = useLibrary({ name: query, workId, addedWithinDays: periods.find(item => item.id === period)!.days });
|
|
42
|
+
const works = library.data?.works ?? [];
|
|
43
|
+
const files = library.data?.files ?? [];
|
|
44
|
+
const sharers = useDirectoryUsers(files.map(file => file.sharedByUserId));
|
|
45
|
+
const filtered = name !== "" || workId !== "" || period !== "any";
|
|
46
|
+
const clear = () => { setName(""); setWorkId(""); setPeriod("any"); };
|
|
47
|
+
const title = (file: LibraryFile) => file.workTitle || libraryWorkTitle(works.find(work => work.workId === file.workId));
|
|
48
|
+
// A Library file is one of its Work's Artifacts.
|
|
49
|
+
const open = (file: LibraryFile) => reader.open({ kind: "work-file", label: title(file), workId: file.workId,
|
|
50
|
+
file: create(WorkFileSchema, { workId: file.workId, kind: WorkFileKind.ARTIFACT, name: file.name, sizeBytes: file.sizeBytes, contentType: file.contentType, updatedAt: file.addedAt }) });
|
|
51
|
+
const now = useNow();
|
|
52
|
+
return <div className="mx-auto flex w-full max-w-[960px] flex-col gap-6 pb-24 pt-4">
|
|
53
|
+
<div className="flex flex-wrap gap-3">
|
|
54
|
+
<SearchField aria-label="Find a file" value={name} onChange={setName} className="w-72"><SearchField.Group><SearchField.SearchIcon /><SearchField.Input placeholder="Find a file" /><SearchField.ClearButton aria-label="Clear search" /></SearchField.Group></SearchField>
|
|
55
|
+
<Select aria-label="Work" className="w-72" value={workId || "any"} onChange={key => setWorkId(key === "any" || key == null ? "" : String(key))}>
|
|
56
|
+
<Select.Trigger><Select.Value /><Select.Indicator /></Select.Trigger>
|
|
57
|
+
<Select.Popover><ListBox>
|
|
58
|
+
<ListBox.Item id="any" textValue="Any Work">Any Work<ListBox.ItemIndicator /></ListBox.Item>
|
|
59
|
+
{works.map(work => <ListBox.Item key={work.workId} id={work.workId} textValue={libraryWorkTitle(work)}><Label className="truncate">{libraryWorkTitle(work)}</Label><ListBox.ItemIndicator /></ListBox.Item>)}
|
|
60
|
+
</ListBox></Select.Popover>
|
|
61
|
+
</Select>
|
|
62
|
+
<Select aria-label="Added" className="w-44" value={period} onChange={key => setPeriod((periods.find(item => item.id === key) ?? periods[0]).id)}>
|
|
63
|
+
<Select.Trigger><Select.Value /><Select.Indicator /></Select.Trigger>
|
|
64
|
+
<Select.Popover><ListBox>{periods.map(item => <ListBox.Item key={item.id} id={item.id} textValue={item.label}>{item.label}<ListBox.ItemIndicator /></ListBox.Item>)}</ListBox></Select.Popover>
|
|
65
|
+
</Select>
|
|
66
|
+
</div>
|
|
67
|
+
{library.isError && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Your Library couldn’t be loaded.</Alert.Description></Alert.Content><Button size="sm" variant="secondary" onPress={() => void library.refetch()}>Retry</Button></Alert>}
|
|
68
|
+
{/* A row opens its file; beside the reading area the table scrolls rather than squeezing its columns. */}
|
|
69
|
+
<Table><Table.ScrollContainer><Table.Content aria-label="Files" className="w-full min-w-[640px]" onRowAction={key => { const file = files.find(item => rowKey(item) === key); if (file) open(file); }}>
|
|
70
|
+
<Table.Header><Table.Column isRowHeader>Name</Table.Column><Table.Column>Work</Table.Column><Table.Column className="w-44">Shared by</Table.Column><Table.Column className="w-28">Added</Table.Column><Table.Column className="w-24">Size</Table.Column></Table.Header>
|
|
71
|
+
<Table.Body renderEmptyState={() => <div className="flex flex-col items-center gap-2 py-8 text-sm text-muted">
|
|
72
|
+
{library.isPending ? <span role="status">Loading your Library…</span> : library.isError ? null
|
|
73
|
+
: filtered ? <>No files match these filters.<Button size="sm" variant="secondary" onPress={clear}>Clear filters</Button></> : "Files your Works create show up here."}
|
|
74
|
+
</div>}>
|
|
75
|
+
{files.map(file => {
|
|
76
|
+
const sharer = file.sharedByUserId ? sharers.data?.get(file.sharedByUserId) : undefined;
|
|
77
|
+
return <Table.Row key={rowKey(file)} id={rowKey(file)}>
|
|
78
|
+
<Table.Cell><Button variant="ghost" size="sm" className="-ms-2 justify-start" onPress={() => open(file)} aria-label={`Preview ${file.name}`}><FileText size={16} className="text-muted" aria-hidden="true" /><span className="truncate">{file.name}</span></Button></Table.Cell>
|
|
79
|
+
<Table.Cell><Link className="text-muted hover:underline" to={personal.work(file.workId)}>{title(file)}</Link></Table.Cell>
|
|
80
|
+
<Table.Cell>{file.sharedByUserId && (sharer ? <Person user={sharer} /> : <span className="text-muted">Someone</span>)}</Table.Cell>
|
|
81
|
+
<Table.Cell>{file.addedAt ? formatAgo(now - timestampDate(file.addedAt).getTime()) : "—"}</Table.Cell>
|
|
82
|
+
<Table.Cell>{formatBytes(Number(file.sizeBytes))}</Table.Cell>
|
|
83
|
+
</Table.Row>;
|
|
84
|
+
})}
|
|
85
|
+
</Table.Body>
|
|
86
|
+
</Table.Content></Table.ScrollContainer></Table>
|
|
87
|
+
</div>;
|
|
88
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useNavigate } from "react-router-dom";
|
|
2
|
+
import { PageLayout } from "../../components/shell/page-layout";
|
|
3
|
+
import { useEntryShell } from "../../components/shell/entry-shell";
|
|
4
|
+
import { WorkStartForm } from "../../components/work-start/work-start-form";
|
|
5
|
+
import { personalWorkStartSource, useStartPersonalWork } from "../../lib/personal-work-service";
|
|
6
|
+
import type { WorkStartSource } from "../../lib/work-start-source";
|
|
7
|
+
|
|
8
|
+
const source: WorkStartSource = { ...personalWorkStartSource, draft: { key: "personal", instruction: "" } };
|
|
9
|
+
|
|
10
|
+
/** New work: the start form with nothing above it but the question. A
|
|
11
|
+
* Personal Work's first input names it. */
|
|
12
|
+
export function NewWorkPage() {
|
|
13
|
+
const navigate = useNavigate();
|
|
14
|
+
const { personal } = useEntryShell();
|
|
15
|
+
const start = useStartPersonalWork();
|
|
16
|
+
return <PageLayout workspaceTitle="New work">
|
|
17
|
+
<div className="agent-grid">
|
|
18
|
+
<section className="stream-panel stream-panel-starting" aria-label="Start work"><div className="wpb-stage">
|
|
19
|
+
<h2 className="wpb-heading">What do you want to work on?</h2>
|
|
20
|
+
<WorkStartForm source={source} onStart={async input => { navigate(personal.work(await start.mutateAsync(input))); }} />
|
|
21
|
+
</div></section>
|
|
22
|
+
</div>
|
|
23
|
+
</PageLayout>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Avatar, Label } from "@heroui/react";
|
|
2
|
+
import type { DirectoryUser } from "@talgent/workbench-contracts/agent/public/v1/personal_work_pb";
|
|
3
|
+
import { avatarInitial, personName, safeAvatarUrl } from "../../lib/user-display";
|
|
4
|
+
|
|
5
|
+
/** A person of the User Directory: avatar, name and username. */
|
|
6
|
+
export function Person({ user }: { user: DirectoryUser }) {
|
|
7
|
+
const name = personName(user);
|
|
8
|
+
const avatar = safeAvatarUrl(user.avatarUrl);
|
|
9
|
+
return <span className="flex min-w-0 items-center gap-2">
|
|
10
|
+
<Avatar size="sm" className="size-6 shrink-0">{avatar && <Avatar.Image src={avatar} alt="" />}<Avatar.Fallback className="text-[10px]">{avatarInitial(name, "", "?")}</Avatar.Fallback></Avatar>
|
|
11
|
+
<Label className="truncate">{name}</Label>{user.username && <span className="truncate text-xs text-muted">@{user.username}</span>}
|
|
12
|
+
</span>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
3
|
+
import { Alert, Button } from "@heroui/react";
|
|
4
|
+
import { WorkFileKind, type WorkFile } from "@talgent/workbench-contracts/agent/public/v1/work_file_pb";
|
|
5
|
+
import { Plus } from "lucide-react";
|
|
6
|
+
import { AttachmentConflictDialog, FileSection, type FileRow } from "../../components/files/file-section";
|
|
7
|
+
import { useReadingArea } from "../../components/shell/reading-context";
|
|
8
|
+
import { WorkTokenSection } from "../../components/work/token-usage";
|
|
9
|
+
import { WorkContextRail } from "../../components/work/work-context-rail";
|
|
10
|
+
import { useWorkProgressSection } from "../../components/work/work-progress-section";
|
|
11
|
+
import { useSubAgentsSection } from "../../components/work/subagents-section";
|
|
12
|
+
import { useCurrentUser } from "../../lib/account-service";
|
|
13
|
+
import { NameTakenError } from "../../lib/name-taken-error";
|
|
14
|
+
import type { SubAgent } from "../../lib/subagents";
|
|
15
|
+
import { useWorkTrace } from "../../lib/use-work-trace";
|
|
16
|
+
import { useCreateWorkFileShareLink, useDeleteWorkFile, useDownloadWorkFile, useRenameWorkAttachment, useStopWorkFileSharing, useUploadWorkAttachment, useWorkFiles, workFileKey } from "../../lib/work-file-service";
|
|
17
|
+
import type { WorkProgress } from "../../lib/work-progress";
|
|
18
|
+
import { isTerminalWorkLifecycle } from "../../lib/work-state-presentation";
|
|
19
|
+
|
|
20
|
+
/** A Work's context card: its Progress, Agents, files and Token Usage. Its
|
|
21
|
+
* owner changes its files and reads its usage; a Work Share holder reads its
|
|
22
|
+
* files. Only a Personal Work lists its files here (`withFiles`). */
|
|
23
|
+
export function PersonalWorkContext({ workId, title, owner, withFiles, workLifecycle, workCreatedAt, progress, subAgents }: {
|
|
24
|
+
workId: string; title: string; owner: boolean; withFiles: boolean; workLifecycle: string; workCreatedAt: Date | null; progress: WorkProgress | null | undefined; subAgents: SubAgent[] | undefined;
|
|
25
|
+
}) {
|
|
26
|
+
const reader = useReadingArea();
|
|
27
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
28
|
+
const visible = !collapsed && !reader.content;
|
|
29
|
+
const progressSection = useWorkProgressSection({ progress, scope: workId, visible });
|
|
30
|
+
const subAgentsSection = useSubAgentsSection({ agents: subAgents, scope: workId, visible });
|
|
31
|
+
const files = useWorkFiles(withFiles ? workId : undefined);
|
|
32
|
+
const closed = workLifecycle === "closed";
|
|
33
|
+
const trace = useWorkTrace({ projectId: "", agentWorkId: owner ? workId : null, workCreatedAt, isWorkTerminal: isTerminalWorkLifecycle(workLifecycle) });
|
|
34
|
+
const hasTokens = owner && Boolean(trace.data?.totals.length || trace.data?.buckets.length || trace.isError);
|
|
35
|
+
const listed = files.data ?? [];
|
|
36
|
+
const artifacts = listed.filter(file => file.kind === WorkFileKind.ARTIFACT);
|
|
37
|
+
const attachments = listed.filter(file => file.kind === WorkFileKind.ATTACHMENT);
|
|
38
|
+
const editable = withFiles && owner && !closed;
|
|
39
|
+
const showAttachments = attachments.length > 0 || editable;
|
|
40
|
+
const upload = useUploadWorkAttachment(workId);
|
|
41
|
+
// An upload refused for a taken name waits for the person to choose Replace.
|
|
42
|
+
const [conflict, setConflict] = useState<File | null>(null);
|
|
43
|
+
const uploadFile = (file: File, replace = false) => upload.mutate({ file, replace }, { onError: error => { if (error instanceof NameTakenError) { upload.reset(); setConflict(file); } } });
|
|
44
|
+
const fileInput = useRef<HTMLInputElement>(null);
|
|
45
|
+
const open = (file: WorkFile) => reader.open({ kind: "work-file", label: title, workId, file });
|
|
46
|
+
const hasContent = Boolean(progress?.items.length || subAgentsSection || artifacts.length || showAttachments || hasTokens);
|
|
47
|
+
|
|
48
|
+
return <WorkContextRail hasContent={hasContent} collapsed={collapsed} onCollapsedChange={setCollapsed} beside={!hasContent && files.isLoading && <p className="sr-only" role="status">Loading context…</p>}>
|
|
49
|
+
{progressSection}
|
|
50
|
+
{subAgentsSection}
|
|
51
|
+
{artifacts.length > 0 && <WorkFileSection title="Artifacts" workId={workId} files={artifacts} owner={owner} editable={editable} onOpen={open} />}
|
|
52
|
+
{/* Attachments stays while it can take an upload, so its heading's + is always reachable. */}
|
|
53
|
+
{showAttachments && <WorkFileSection title="Attachments" workId={workId} files={attachments} owner={owner} editable={editable} onOpen={open}
|
|
54
|
+
action={editable && <Button isIconOnly size="sm" variant="ghost" aria-label="Add attachment" isPending={upload.isPending} onPress={() => fileInput.current?.click()}><Plus size={16} /></Button>} />}
|
|
55
|
+
<AttachmentConflictDialog conflict={conflict} onClose={() => setConflict(null)} onReplace={file => uploadFile(file, true)} />
|
|
56
|
+
<input ref={fileInput} type="file" hidden aria-label="Upload attachment file" onChange={event => { const file = event.target.files?.[0]; if (file) uploadFile(file); event.target.value = ""; }} />
|
|
57
|
+
{files.isError && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Files couldn’t be loaded.</Alert.Description></Alert.Content><Button size="sm" variant="ghost" onPress={() => void files.refetch()}>Retry</Button></Alert>}
|
|
58
|
+
{hasTokens && <WorkTokenSection totals={trace.data?.totals ?? []} onOpenTimeline={() => reader.open({ kind: "trace", label: title, projectId: "", workId, createdAt: workCreatedAt })} />}
|
|
59
|
+
{upload.error && !(upload.error instanceof NameTakenError) && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>{upload.error.message}</Alert.Description></Alert.Content></Alert>}
|
|
60
|
+
</WorkContextRail>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The owner shares and changes the Work's files; a Work Share holder downloads them.
|
|
64
|
+
function WorkFileSection({ title, workId, files, owner, editable, onOpen, action }: {
|
|
65
|
+
title: string; workId: string; files: WorkFile[]; owner: boolean; editable: boolean; onOpen: (file: WorkFile) => void; action?: ReactNode;
|
|
66
|
+
}) {
|
|
67
|
+
const userId = useCurrentUser().data?.user?.id;
|
|
68
|
+
const download = useDownloadWorkFile(workId);
|
|
69
|
+
const share = useCreateWorkFileShareLink(workId);
|
|
70
|
+
const unshare = useStopWorkFileSharing(workId);
|
|
71
|
+
const removal = useDeleteWorkFile(workId);
|
|
72
|
+
const rename = useRenameWorkAttachment(workId);
|
|
73
|
+
const upload = useUploadWorkAttachment(workId);
|
|
74
|
+
const byKey = new Map(files.map(file => [workFileKey(file), file]));
|
|
75
|
+
const file = (row: FileRow) => byKey.get(row.key)!;
|
|
76
|
+
const rows: FileRow[] = files.map(file => ({
|
|
77
|
+
key: workFileKey(file), name: file.name, attachment: file.kind === WorkFileKind.ATTACHMENT, sizeBytes: Number(file.sizeBytes), contentType: file.contentType,
|
|
78
|
+
modifiedAt: file.updatedAt ? timestampDate(file.updatedAt) : null, shared: file.shareLinks.length > 0, ownLink: file.shareLinks.find(link => link.createdByUserId === userId)?.url,
|
|
79
|
+
}));
|
|
80
|
+
return <FileSection title={title} rows={rows} onOpen={row => onOpen(file(row))} action={action} actions={{
|
|
81
|
+
download: row => download.mutateAsync(file(row)),
|
|
82
|
+
...(owner && {
|
|
83
|
+
share: async (row: FileRow) => (await share.mutateAsync(file(row)))?.url,
|
|
84
|
+
unshare: async (row: FileRow) => { await unshare.mutateAsync(file(row)); return { status: "success" as const, title: "Sharing stopped", description: `Links to ${row.name} no longer open it.` }; },
|
|
85
|
+
}),
|
|
86
|
+
...(editable && {
|
|
87
|
+
rename: (row: FileRow, newName: string) => rename.mutateAsync({ name: row.name, newName }),
|
|
88
|
+
replace: (_: FileRow, named: File) => upload.mutateAsync({ file: named, replace: true }),
|
|
89
|
+
remove: (row: FileRow) => removal.mutateAsync(file(row)),
|
|
90
|
+
}),
|
|
91
|
+
removeNote: "The file is removed from this Work right away. Its share links stop working.",
|
|
92
|
+
}} />;
|
|
93
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useWorkComputer } from "../../lib/computer-service";
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
4
|
+
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
5
|
+
import type { StartupProgress } from "@talgent/workbench-contracts/agent/public/v1/startup_pb";
|
|
6
|
+
import { useParams } from "react-router-dom";
|
|
7
|
+
import { PageLayout } from "../../components/shell/page-layout";
|
|
8
|
+
import { ConversationUnavailable } from "../../components/work/conversation-unavailable";
|
|
9
|
+
import { WorkConversation } from "../../components/work/work-conversation";
|
|
10
|
+
import { WorkSnapshotUnavailable } from "../../components/work/work-snapshot-state";
|
|
11
|
+
import { useCurrentUser } from "../../lib/account-service";
|
|
12
|
+
import { useAgentWork } from "../../lib/agent-work-service";
|
|
13
|
+
import { useLibraryWork, usePersonalWorks } from "../../lib/personal-work-service";
|
|
14
|
+
import { newestStartupProgress } from "../../lib/startup-progress";
|
|
15
|
+
import type { SubAgent } from "../../lib/subagents";
|
|
16
|
+
import { useUploadWorkAttachment } from "../../lib/work-file-service";
|
|
17
|
+
import { adaptWorkProgress } from "../../lib/work-progress";
|
|
18
|
+
import { PersonalWorkContext } from "./personal-work-context";
|
|
19
|
+
import { ShareButton } from "./share-dialog";
|
|
20
|
+
import { TitleButton } from "./title-dialog";
|
|
21
|
+
|
|
22
|
+
/** A Personal Work: its conversation and context card, titled by the Work.
|
|
23
|
+
* Its owner continues, renames and shares it; a Work Share holder reads it. */
|
|
24
|
+
export function PersonalWorkPage() {
|
|
25
|
+
const { workId = "" } = useParams();
|
|
26
|
+
const userId = useCurrentUser().data?.user?.id ?? "";
|
|
27
|
+
const snapshot = useAgentWork(workId);
|
|
28
|
+
const work = snapshot.data?.work;
|
|
29
|
+
const computer = useWorkComputer(work?.computerId, Boolean(work?.permissionMode));
|
|
30
|
+
const refused = snapshot.error ? ConnectError.from(snapshot.error).code : undefined;
|
|
31
|
+
// Agent shows a Work only to its owner; anyone else may still hold a Work Share.
|
|
32
|
+
const reader = refused === Code.PermissionDenied;
|
|
33
|
+
const own = usePersonalWorks();
|
|
34
|
+
const shared = useLibraryWork(workId, reader);
|
|
35
|
+
// Only a Personal Work has a title of its own and is shared from here; a
|
|
36
|
+
// Work of a Project is the Project's to share.
|
|
37
|
+
const personal = own.data?.find(item => item.id === workId);
|
|
38
|
+
// A Work with a Project keeps its files in the Project's spaces, not here.
|
|
39
|
+
const personalWork = work ? Boolean(personal) : Boolean(shared.data?.personal);
|
|
40
|
+
const title = (work ? personal?.title : shared.data?.title) || (reader ? "Shared work" : "Work");
|
|
41
|
+
const [live, setLive] = useState<{ workId: string; lifecycle: string; startup?: StartupProgress }>({ workId: "", lifecycle: "" });
|
|
42
|
+
const onLifecycle = useCallback((lifecycle: string, _updatedAt?: Date, startup?: StartupProgress) => setLive({ workId, lifecycle, startup }), [workId]);
|
|
43
|
+
const [liveSubAgents, setLiveSubAgents] = useState<{ workId: string; agents: SubAgent[] }>();
|
|
44
|
+
const onSubAgents = useCallback((agents: SubAgent[]) => setLiveSubAgents({ workId, agents }), [workId]);
|
|
45
|
+
const [withdrawn, setWithdrawn] = useState<string | null>(null);
|
|
46
|
+
const upload = useUploadWorkAttachment(workId);
|
|
47
|
+
const liveLifecycle = live.workId === workId ? live.lifecycle : "";
|
|
48
|
+
const workLifecycle = liveLifecycle || work?.state || "";
|
|
49
|
+
const startupProgress = newestStartupProgress(live.workId === workId ? live.startup : undefined, work?.startupProgress);
|
|
50
|
+
const pending = snapshot.isPending || (reader && (shared.isPending || !liveLifecycle));
|
|
51
|
+
const starting = workLifecycle !== "closed" && (pending || Boolean(startupProgress ? startupProgress.status !== "completed" : ["created", "starting"].includes(workLifecycle)));
|
|
52
|
+
const refresh = useCallback(() => { void snapshot.refetch(); }, [snapshot]);
|
|
53
|
+
|
|
54
|
+
// Refused by Agent and not in the person's Library: no Work Share to read it by.
|
|
55
|
+
const unavailable = withdrawn === workId ? <ConversationUnavailable withdrawn />
|
|
56
|
+
: reader && shared.data === null ? <ConversationUnavailable />
|
|
57
|
+
: refused === Code.NotFound ? <WorkSnapshotUnavailable notFound onRetry={refresh} />
|
|
58
|
+
: snapshot.isError && !reader ? <WorkSnapshotUnavailable notFound={false} onRetry={refresh} />
|
|
59
|
+
: null;
|
|
60
|
+
return <PageLayout workspaceTitle={title} topActions={work && personal && <>
|
|
61
|
+
<TitleButton workId={workId} title={title} />
|
|
62
|
+
<ShareButton workId={workId} ownerId={userId} readerIds={work.shareReaderIds} />
|
|
63
|
+
</>}>
|
|
64
|
+
<div className="agent-grid">
|
|
65
|
+
{unavailable ? <section data-testid="agent-stream-panel" className="stream-panel" aria-label="Agent Stream">{unavailable}</section>
|
|
66
|
+
: (work || (reader && !shared.isPending)) && <WorkConversation
|
|
67
|
+
key={workId}
|
|
68
|
+
agentWorkId={workId}
|
|
69
|
+
label={title}
|
|
70
|
+
readOnly={reader}
|
|
71
|
+
onAccessWithdrawn={() => setWithdrawn(workId)}
|
|
72
|
+
workLifecycle={workLifecycle}
|
|
73
|
+
stateReason={work?.stateReason}
|
|
74
|
+
disconnectedSince={work?.disconnectedSince ? timestampDate(work.disconnectedSince) : undefined}
|
|
75
|
+
resumeOnReconnection={work?.resumeOnReconnection}
|
|
76
|
+
isWorkLifecyclePending={pending && !liveLifecycle}
|
|
77
|
+
startupProgress={startupProgress}
|
|
78
|
+
startupRequest={work?.startupRequest}
|
|
79
|
+
workModel={work?.model}
|
|
80
|
+
workModelOption={work?.modelOption}
|
|
81
|
+
permissionMode={work?.permissionMode}
|
|
82
|
+
computer={computer}
|
|
83
|
+
failureReason={work?.diagnosticReason}
|
|
84
|
+
sandbox={work?.sandbox}
|
|
85
|
+
allowedActions={work?.allowedActions}
|
|
86
|
+
deskWaiting={work?.deskWaiting}
|
|
87
|
+
workVersion={work?.version}
|
|
88
|
+
workControlPending={Boolean(work?.archivePending || work?.resumePending)}
|
|
89
|
+
onWorkControlChange={refresh}
|
|
90
|
+
onWorkLifecycleChange={onLifecycle}
|
|
91
|
+
onSubAgentsChange={onSubAgents}
|
|
92
|
+
onUploadAttachment={work && personalWork ? file => upload.mutateAsync({ file }) : undefined}
|
|
93
|
+
/>}
|
|
94
|
+
{!unavailable && !snapshot.isPending && !starting && <PersonalWorkContext workId={workId} title={title} owner={Boolean(work)} withFiles={personalWork} workLifecycle={workLifecycle}
|
|
95
|
+
workCreatedAt={work?.createdAt ? timestampDate(work.createdAt) : null} progress={work ? adaptWorkProgress(work.workProgress) : undefined}
|
|
96
|
+
subAgents={liveSubAgents?.workId === workId ? liveSubAgents.agents : undefined} />}
|
|
97
|
+
{snapshot.isPending && <span className="sr-only" role="status">Loading work…</span>}
|
|
98
|
+
</div>
|
|
99
|
+
</PageLayout>;
|
|
100
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Alert, Button, Table, buttonVariants } from "@heroui/react";
|
|
2
|
+
import { Plus } from "lucide-react";
|
|
3
|
+
import { Link, useNavigate } from "react-router-dom";
|
|
4
|
+
import { formatAgo } from "../../components/analytics/work-usage/work-status";
|
|
5
|
+
import { PageLayout } from "../../components/shell/page-layout";
|
|
6
|
+
import { useEntryShell } from "../../components/shell/entry-shell";
|
|
7
|
+
import { usePersonalWorks } from "../../lib/personal-work-service";
|
|
8
|
+
import { useNow } from "../../lib/use-now";
|
|
9
|
+
import { workPresentation } from "../../lib/work-state-presentation";
|
|
10
|
+
|
|
11
|
+
/** The person's Personal Works, most recently updated first, for an entry
|
|
12
|
+
* that lists them on a page of their own. */
|
|
13
|
+
export function PersonalWorksPage() {
|
|
14
|
+
const { personal } = useEntryShell();
|
|
15
|
+
const works = usePersonalWorks();
|
|
16
|
+
const navigate = useNavigate();
|
|
17
|
+
const now = useNow();
|
|
18
|
+
return <PageLayout workspaceTitle="Personal works" topActions={<Link to={personal.newWork} className={buttonVariants({ size: "sm" })}><Plus size={16} aria-hidden="true" />New work</Link>}>
|
|
19
|
+
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-6 pb-24 pt-4">
|
|
20
|
+
{works.isError && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Your works couldn’t be loaded.</Alert.Description></Alert.Content><Button size="sm" variant="secondary" onPress={() => void works.refetch()}>Retry</Button></Alert>}
|
|
21
|
+
<Table><Table.ScrollContainer><Table.Content aria-label="Personal works" className="w-full" onRowAction={key => navigate(personal.work(String(key)))}>
|
|
22
|
+
<Table.Header><Table.Column isRowHeader>Work</Table.Column><Table.Column className="w-56">State</Table.Column><Table.Column className="w-28">Updated</Table.Column></Table.Header>
|
|
23
|
+
<Table.Body renderEmptyState={() => <div className="py-8 text-center text-sm text-muted">{works.isPending ? <span role="status">Loading your works…</span> : works.isError ? null : "Works you start for yourself show up here."}</div>}>
|
|
24
|
+
{(works.data ?? []).map(work => {
|
|
25
|
+
const state = workPresentation(work);
|
|
26
|
+
return <Table.Row key={work.id} id={work.id}>
|
|
27
|
+
<Table.Cell><Link className="hover:underline" to={personal.work(work.id)}>{work.title}</Link></Table.Cell>
|
|
28
|
+
<Table.Cell>{state ? <span className="flex items-center gap-1.5 whitespace-nowrap text-sm"><state.icon size={14} aria-hidden="true" />{state.label}{state.description && <span className="sr-only">. {state.description}</span>}</span> : <span className="text-muted">—</span>}</Table.Cell>
|
|
29
|
+
<Table.Cell>{work.updatedAt ? formatAgo(now - work.updatedAt.getTime()) : "—"}</Table.Cell>
|
|
30
|
+
</Table.Row>;
|
|
31
|
+
})}
|
|
32
|
+
</Table.Body>
|
|
33
|
+
</Table.Content></Table.ScrollContainer></Table>
|
|
34
|
+
</div>
|
|
35
|
+
</PageLayout>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Alert, Button, ListBox, Modal, SearchField, Spinner } from "@heroui/react";
|
|
3
|
+
import { Share2 } from "lucide-react";
|
|
4
|
+
import { useDirectoryUsers, useGrantPersonalWorkShare, useRevokeWorkShare, useShareCandidates } from "../../lib/personal-work-service";
|
|
5
|
+
import { personName } from "../../lib/user-display";
|
|
6
|
+
import { Person } from "./person";
|
|
7
|
+
|
|
8
|
+
/** Share: the people a Personal Work's owner lets read it. Anyone in the
|
|
9
|
+
* User Directory may be added; the Work stays private to the rest. */
|
|
10
|
+
export function ShareButton({ workId, ownerId, readerIds }: { workId: string; ownerId: string; readerIds: string[] }) {
|
|
11
|
+
const [open, setOpen] = useState(false);
|
|
12
|
+
return <Modal isOpen={open} onOpenChange={setOpen}>
|
|
13
|
+
<Button size="sm" variant="secondary"><Share2 size={15} aria-hidden="true" />Share</Button>
|
|
14
|
+
<Modal.Backdrop><Modal.Container placement="center" size="md"><Modal.Dialog>
|
|
15
|
+
<Modal.CloseTrigger />
|
|
16
|
+
<Modal.Header><Modal.Heading>Share this work</Modal.Heading></Modal.Header>
|
|
17
|
+
<Modal.Body className="grid gap-4"><ShareReaders workId={workId} ownerId={ownerId} readerIds={readerIds} /></Modal.Body>
|
|
18
|
+
</Modal.Dialog></Modal.Container></Modal.Backdrop>
|
|
19
|
+
</Modal>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ShareReaders({ workId, ownerId, readerIds }: { workId: string; ownerId: string; readerIds: string[] }) {
|
|
23
|
+
const [query, setQuery] = useState("");
|
|
24
|
+
const candidates = useShareCandidates(query);
|
|
25
|
+
const readers = useDirectoryUsers(readerIds);
|
|
26
|
+
const grant = useGrantPersonalWorkShare(workId);
|
|
27
|
+
const revoke = useRevokeWorkShare(workId);
|
|
28
|
+
const offered = (candidates.data ?? []).filter(user => user.userId !== ownerId && !readerIds.includes(user.userId));
|
|
29
|
+
const error = grant.error ?? revoke.error;
|
|
30
|
+
return <>
|
|
31
|
+
<p className="text-sm text-muted">People you add can read the conversation and its files. They can’t send instructions.</p>
|
|
32
|
+
<SearchField aria-label="Find people" value={query} onChange={setQuery} autoFocus>
|
|
33
|
+
<SearchField.Group><SearchField.SearchIcon /><SearchField.Input placeholder="Find people by username" /><SearchField.ClearButton aria-label="Clear search" /></SearchField.Group>
|
|
34
|
+
</SearchField>
|
|
35
|
+
{query.trim() && <div aria-live="polite">
|
|
36
|
+
{candidates.isPending ? <span className="flex items-center gap-2 text-sm text-muted"><Spinner size="sm" />Searching…</span>
|
|
37
|
+
: candidates.isError ? <span className="text-sm text-danger" role="alert">People couldn’t be found. <Button size="sm" variant="ghost" onPress={() => void candidates.refetch()}>Retry</Button></span>
|
|
38
|
+
: offered.length === 0 ? <span className="text-sm text-muted">No one else matches “{query.trim()}”.</span>
|
|
39
|
+
: <ListBox aria-label="People to add" selectionMode="none" onAction={key => { const user = offered.find(item => item.userId === key); if (user) grant.mutate(user, { onSuccess: () => setQuery("") }); }}>
|
|
40
|
+
{offered.map(user => <ListBox.Item key={user.userId} id={user.userId} textValue={personName(user)}><Person user={user} /><span className="ms-auto text-xs text-muted">Add</span></ListBox.Item>)}
|
|
41
|
+
</ListBox>}
|
|
42
|
+
</div>}
|
|
43
|
+
{error && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>{error.message}</Alert.Description></Alert.Content></Alert>}
|
|
44
|
+
<section aria-label="People with access" className="grid gap-2">
|
|
45
|
+
<h3 className="text-sm font-medium">People with access</h3>
|
|
46
|
+
{readerIds.length === 0 ? <p className="text-sm text-muted">Only you can read this work.</p>
|
|
47
|
+
: <ul className="grid gap-1">{readerIds.map(id => {
|
|
48
|
+
const user = readers.data?.get(id);
|
|
49
|
+
return <li key={id} className="flex items-center gap-2">
|
|
50
|
+
{user ? <Person user={user} /> : <span className="text-sm text-muted">{readers.isPending ? "Loading…" : "Someone no longer in the directory"}</span>}
|
|
51
|
+
<Button size="sm" variant="ghost" className="ms-auto" isPending={revoke.isPending && revoke.variables === id} onPress={() => revoke.mutate(id)} aria-label={`Remove ${user ? personName(user) : "this person"}`}>Remove</Button>
|
|
52
|
+
</li>;
|
|
53
|
+
})}</ul>}
|
|
54
|
+
</section>
|
|
55
|
+
{grant.isPending && <p className="sr-only" role="status">Sharing…</p>}
|
|
56
|
+
</>;
|
|
57
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Alert, Button, Input, Label, Modal, TextField, Tooltip } from "@heroui/react";
|
|
3
|
+
import { Pencil } from "lucide-react";
|
|
4
|
+
import { useChangePersonalWorkTitle } from "../../lib/personal-work-service";
|
|
5
|
+
|
|
6
|
+
/** Renames a Personal Work: its display title starts from its first input
|
|
7
|
+
* and is the owner's to change. */
|
|
8
|
+
export function TitleButton({ workId, title }: { workId: string; title: string }) {
|
|
9
|
+
const [open, setOpen] = useState(false);
|
|
10
|
+
return <>
|
|
11
|
+
<Tooltip><Button size="sm" variant="ghost" isIconOnly aria-label="Rename work" onPress={() => setOpen(true)}><Pencil size={15} aria-hidden="true" /></Button><Tooltip.Content>Rename work</Tooltip.Content></Tooltip>
|
|
12
|
+
{open && <TitleDialog workId={workId} title={title} onClose={() => setOpen(false)} />}
|
|
13
|
+
</>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function TitleDialog({ workId, title, onClose }: { workId: string; title: string; onClose: () => void }) {
|
|
17
|
+
const [value, setValue] = useState(title);
|
|
18
|
+
const change = useChangePersonalWorkTitle(workId);
|
|
19
|
+
const trimmed = value.trim();
|
|
20
|
+
const submit = () => {
|
|
21
|
+
if (!trimmed || trimmed === title || change.isPending) return;
|
|
22
|
+
change.mutate(trimmed, { onSuccess: onClose });
|
|
23
|
+
};
|
|
24
|
+
return <Modal><Modal.Backdrop isOpen onOpenChange={open => { if (!open && !change.isPending) onClose(); }} isDismissable={!change.isPending}><Modal.Container placement="center" size="sm"><Modal.Dialog>
|
|
25
|
+
<Modal.Header><Modal.Heading>Rename work</Modal.Heading></Modal.Header>
|
|
26
|
+
<Modal.Body>
|
|
27
|
+
<TextField value={value} onChange={setValue} isInvalid={change.isError} autoFocus>
|
|
28
|
+
<Label>Title</Label>
|
|
29
|
+
<Input onKeyDown={event => { if (event.key === "Enter") submit(); }} />
|
|
30
|
+
</TextField>
|
|
31
|
+
{change.error && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>{change.error.message}</Alert.Description></Alert.Content></Alert>}
|
|
32
|
+
</Modal.Body>
|
|
33
|
+
<Modal.Footer><Button variant="secondary" isDisabled={change.isPending} onPress={onClose}>Cancel</Button><Button isPending={change.isPending} isDisabled={!trimmed || trimmed === title} onPress={submit}>Rename</Button></Modal.Footer>
|
|
34
|
+
</Modal.Dialog></Modal.Container></Modal.Backdrop></Modal>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { Alert, Button, Tabs } from "@heroui/react";
|
|
3
|
+
import { FileText, FolderKanban } from "lucide-react";
|
|
4
|
+
import { useSearchParams } from "react-router-dom";
|
|
5
|
+
import { TokenReport, type TokenRanking } from "../../components/analytics/token-report";
|
|
6
|
+
import { PageLayout } from "../../components/shell/page-layout";
|
|
7
|
+
import { dashboardReportPeriod } from "../../lib/dashboard-report";
|
|
8
|
+
import { usePersonalTokenAnalytics } from "../../lib/personal-usage-service";
|
|
9
|
+
import { untitledWorkLabel, usePersonalWorks } from "../../lib/personal-work-service";
|
|
10
|
+
import { dashboardWindows, parseDashboardWindow } from "../../lib/dashboard";
|
|
11
|
+
import type { TokenAnalytics } from "../../lib/token-analytics";
|
|
12
|
+
import { tokenTotals } from "../../lib/trace-view-model";
|
|
13
|
+
|
|
14
|
+
const topWorkCount = 10;
|
|
15
|
+
|
|
16
|
+
/** Usage: the person's usage across all their Works, laid out as Insights'
|
|
17
|
+
* Spend, its one ranking being Top works. */
|
|
18
|
+
export function UsagePage() {
|
|
19
|
+
const [search, setSearch] = useSearchParams();
|
|
20
|
+
const range = parseDashboardWindow(search.get("window"));
|
|
21
|
+
// The window moves with the clock, as Insights' does.
|
|
22
|
+
const [anchor, setAnchor] = useState(() => new Date());
|
|
23
|
+
useEffect(() => { const timer = window.setInterval(() => setAnchor(new Date()), 30_000); return () => window.clearInterval(timer); }, []);
|
|
24
|
+
const period = useMemo(() => dashboardReportPeriod(range, anchor), [range, anchor]);
|
|
25
|
+
const usage = usePersonalTokenAnalytics(range, period);
|
|
26
|
+
const [hover, setHover] = useState<{ index: number; chart: string } | null>(null);
|
|
27
|
+
const windowTabs = <Tabs selectedKey={range} onSelectionChange={key => { setHover(null); setSearch({ window: String(key) }, { replace: true }); }}>
|
|
28
|
+
<Tabs.ListContainer><Tabs.List aria-label="Time window">{dashboardWindows.map(item => <Tabs.Tab key={item} id={item} aria-controls="usage-report">{item.toUpperCase()}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer>
|
|
29
|
+
</Tabs>;
|
|
30
|
+
return <PageLayout workspaceTitle="Usage" topActions={windowTabs}>
|
|
31
|
+
<div id="usage-report" className="flex flex-col gap-4 pb-24">
|
|
32
|
+
{usage.isError && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Your usage couldn’t be loaded.</Alert.Description></Alert.Content><Button size="sm" variant="secondary" isPending={usage.isFetching} onPress={() => void usage.refetch()}>Retry</Button></Alert>}
|
|
33
|
+
{usage.data ? <UsageReport analytics={usage.data} hover={hover} onHover={setHover} /> : !usage.isError && <span className="sr-only" role="status">Loading Usage</span>}
|
|
34
|
+
</div>
|
|
35
|
+
</PageLayout>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function UsageReport({ analytics, hover, onHover }: { analytics: TokenAnalytics; hover: { index: number; chart: string } | null; onHover: (hover: { index: number; chart: string } | null) => void }) {
|
|
39
|
+
const personal = usePersonalWorks();
|
|
40
|
+
const rankings: TokenRanking[] = [{ title: "Top works", rows: topWorks(analytics, new Map((personal.data ?? []).map(work => [work.id, work.title]))) }];
|
|
41
|
+
return <TokenReport analytics={analytics} rankings={rankings} hover={hover} onHover={onHover} />;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A person's usage splits by Work; a Work of a Project shows as one.
|
|
45
|
+
function topWorks(analytics: TokenAnalytics, titles: Map<string, string>): TokenRanking["rows"] {
|
|
46
|
+
const projectWorks = new Set(analytics.metrics.buckets.filter(bucket => bucket.projectId).map(bucket => bucket.agentWorkId ?? ""));
|
|
47
|
+
return [...tokenTotals(analytics.metrics.buckets, "work")].sort((left, right) => right[1] - left[1]).slice(0, topWorkCount).map(([workId, totalTokens]) => {
|
|
48
|
+
const project = projectWorks.has(workId) && !titles.has(workId);
|
|
49
|
+
return { key: workId, totalTokens, label: titles.get(workId) ?? untitledWorkLabel, detail: workId,
|
|
50
|
+
icon: project ? <FolderKanban size={16} aria-hidden="true" /> : <FileText size={16} aria-hidden="true" /> };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 LobeHub
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Product icons
|
|
2
|
+
|
|
3
|
+
Shared by the prototype and production UI. Render with the existing
|
|
4
|
+
`wpb-product-icon` image styles, including the dark-theme inversion.
|
|
5
|
+
|
|
6
|
+
OpenAI, Anthropic, Notion and Linear are the existing product assets moved from
|
|
7
|
+
`assets/prototype/product-icons`.
|
|
8
|
+
|
|
9
|
+
DeepSeek, GLM, Kimi, Qwen and MiniMax are vendored from
|
|
10
|
+
`@lobehub/icons-static-svg@1.95.1` ([Lobe Icons](https://github.com/lobehub/lobe-icons)).
|
|
11
|
+
`glm.svg` is the upstream `chatglm.svg`; the other filenames are unchanged. The
|
|
12
|
+
upstream MIT license for these assets is included in `LICENSE`. Product marks belong to their
|
|
13
|
+
respective owners.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ChatGLM</title><path d="M9.917 2c4.906 0 10.178 3.947 8.93 10.58-.014.07-.037.14-.057.21l-.003-.277c-.083-3-1.534-8.934-8.87-8.934-3.393 0-8.137 3.054-7.93 8.158-.04 4.778 3.555 8.4 7.95 8.332l.073-.001c1.2-.033 2.763-.429 3.1-1.657.063-.031.26.534.268.598.048.256.112.369.192.34.981-.348 2.286-1.222 1.952-2.38-.176-.61-1.775-.147-1.921-.347.418-.979 2.234-.926 3.153-.716.443.102.657.38 1.012.442.29.052.981-.2.96.242C17.226 19.632 13.833 22 9.918 22 3.654 22 0 16.574 0 11.737 0 5.947 4.959 2 9.917 2zM9.9 5.3c.484 0 1.125.225 1.38.585 3.669.145 4.313 2.686 4.694 5.444.255 1.838.315 2.3.182 1.387l.083.59c.068.448.554.737.982.516.144-.075.254-.231.328-.47a.2.2 0 01.258-.13l.625.22a.2.2 0 01.124.238 2.172 2.172 0 01-.51.92c-.878.917-2.757.664-3.08-.62-.14-.554-.055-.626-.345-1.242-.292-.621-1.238-.709-1.69-.295-.345.315-.407.805-.406 1.282L12.6 15.9a.9.9 0 01-.9.9h-1.4a.9.9 0 01-.9-.9v-.65a1.15 1.15 0 10-2.3 0v.65a.9.9 0 01-.9.9H4.8a.9.9 0 01-.9-.9l.035-3.239c.012-1.884.356-3.658 2.47-4.134.2-.045.252.13.29.342.025.154.043.252.053.294.701 3.058 1.75 4.299 3.144 3.722l.66-.331.254-.13c.158-.082.25-.131.276-.15.012-.01-.165-.206-.407-.464l-1.012-1.067a8.925 8.925 0 01-.199-.216c-.047-.034-.116.068-.208.306-.074.157-.251.252-.272.326-.013.058.108.298.362.72.164.288.22.508-.31.343-1.04-.8-1.518-2.273-1.684-3.725-.004-.035-.162-1.913-.162-1.913a1.2 1.2 0 011.113-1.281L9.9 5.3zm12.994 8.68c.037.697-.403.704-1.213.591l-1.783-.276c-.265-.053-.385-.099-.313-.147.47-.315 3.268-.93 3.31-.168zm-.915-.083l-.926.042c-.85.077-1.452.24.338.336l.103.003c.815.012 1.264-.359.485-.381zm1.667-3.601h.01c.79.398.067 1.03-.65 1.393-.14.07-.491.176-1.052.315-.241.04-.457.092-.333.16l.01.005c1.952.958-3.123 1.534-2.495 1.285l.38-.148c.68-.266 1.614-.682 1.666-1.337.038-.48 1.253-.442 1.493-.968.048-.106 0-.236-.144-.389-.05-.047-.094-.094-.107-.148-.073-.305.7-.431 1.222-.168zm-2.568-.474c-.135 1.198-2.479 4.192-1.949 2.863l.017-.042c.298-.717.376-2.221 1.337-3.221.25-.26.636.035.595.4zm-7.976-.253c.02-.694 1.002-.968 1.346-.347.01-1.274-1.941-.768-1.346.347z"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Linear</title><path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Notion</title><path d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632z"/></svg>
|