@talgent/workbench-ui 0.0.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,181 @@
|
|
|
1
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
3
|
+
import { Alert, Button, Dropdown, Input, Label, Modal, TextField, Tooltip } from "@heroui/react";
|
|
4
|
+
import { Download, FileCode2, FileImage, FileText, Link2, Link2Off, MoreHorizontal, Pencil, Replace, Trash2 } from "lucide-react";
|
|
5
|
+
import { ConfirmDialog } from "../marketplace/marketplace-shared";
|
|
6
|
+
import { RailSection } from "../rail/rail-section";
|
|
7
|
+
import { notify } from "../../lib/notifications";
|
|
8
|
+
import { shareLinkAddress } from "../../lib/share-link";
|
|
9
|
+
import { FileFacts } from "./file-facts";
|
|
10
|
+
|
|
11
|
+
/** One file of a File Space as its section lists it. */
|
|
12
|
+
export type FileRow = {
|
|
13
|
+
key: string;
|
|
14
|
+
name: string;
|
|
15
|
+
attachment: boolean;
|
|
16
|
+
sizeBytes: number;
|
|
17
|
+
contentType: string;
|
|
18
|
+
modifiedAt: Date | null;
|
|
19
|
+
/** The Work that published an Artifact, when known. */
|
|
20
|
+
publishedBy?: string;
|
|
21
|
+
shared: boolean;
|
|
22
|
+
/** The reader's own Share Link, when they made one. */
|
|
23
|
+
ownLink?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Notice = { status: "success" | "warning"; title: string; description?: string };
|
|
27
|
+
|
|
28
|
+
/** What the reader may do with the files. Absent actions are not offered;
|
|
29
|
+
* `rename`, `replace` and `remove` apply while the files may change. */
|
|
30
|
+
export type FileActions = {
|
|
31
|
+
download: (row: FileRow) => Promise<unknown>;
|
|
32
|
+
/** Makes a Share Link, returning its address. */
|
|
33
|
+
share?: (row: FileRow) => Promise<string | undefined>;
|
|
34
|
+
unshare?: (row: FileRow) => Promise<Notice>;
|
|
35
|
+
rename?: (row: FileRow, newName: string) => Promise<unknown>;
|
|
36
|
+
replace?: (row: FileRow, file: File) => Promise<unknown>;
|
|
37
|
+
remove?: (row: FileRow) => Promise<unknown>;
|
|
38
|
+
/** What deleting removes, said before the person confirms. */
|
|
39
|
+
removeNote: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type FileAction = "download" | "share" | "unshare" | "rename" | "replace" | "delete";
|
|
43
|
+
|
|
44
|
+
// FileSection lists one kind of a File Space's files. Each row previews the
|
|
45
|
+
// file; its menu holds the file's actions.
|
|
46
|
+
export function FileSection({ title, rows, actions, onOpen, action }: {
|
|
47
|
+
title: string; rows: FileRow[]; actions: FileActions; onOpen: (row: FileRow) => void; action?: ReactNode;
|
|
48
|
+
}) {
|
|
49
|
+
const [hoveredFile, setHoveredFile] = useState<string | null>(null);
|
|
50
|
+
const [removing, setRemoving] = useState<FileRow | null>(null);
|
|
51
|
+
const [renaming, setRenaming] = useState<FileRow | null>(null);
|
|
52
|
+
const replaceInput = useRef<HTMLInputElement>(null);
|
|
53
|
+
const [replacing, setReplacing] = useState<FileRow | null>(null);
|
|
54
|
+
// A row is busy while one of its changes is in flight.
|
|
55
|
+
const [busy, setBusy] = useState<string[]>([]);
|
|
56
|
+
const failed = (row: FileRow, verb: string) => (error: Error) => notify(`file-${row.key}`, "danger", `Couldn’t ${verb} ${row.name}`, error.message);
|
|
57
|
+
const done = (row: FileRow, title: string, description?: string) => notify(`file-${row.key}`, "success", title, description);
|
|
58
|
+
const track = <T,>(row: FileRow, work: () => Promise<T>) => {
|
|
59
|
+
setBusy(keys => [...keys, row.key]);
|
|
60
|
+
return work().finally(() => setBusy(keys => keys.filter(key => key !== row.key)));
|
|
61
|
+
};
|
|
62
|
+
const copy = (row: FileRow, url: string) => void navigator.clipboard.writeText(shareLinkAddress(url)).then(
|
|
63
|
+
() => notify(`file-${row.key}`, "success", "Share link copied", `Anyone with the link can view ${row.name}.`),
|
|
64
|
+
() => notify(`file-${row.key}`, "danger", "Couldn’t copy the share link", shareLinkAddress(url)));
|
|
65
|
+
|
|
66
|
+
const run = (row: FileRow, key: FileAction) => {
|
|
67
|
+
switch (key) {
|
|
68
|
+
case "download":
|
|
69
|
+
actions.download(row).catch(failed(row, "download"));
|
|
70
|
+
break;
|
|
71
|
+
case "share":
|
|
72
|
+
// Copy the link the person made; otherwise make one of their own.
|
|
73
|
+
if (row.ownLink) copy(row, row.ownLink);
|
|
74
|
+
else actions.share?.(row).then(url => { if (url) copy(row, url); }, failed(row, "share"));
|
|
75
|
+
break;
|
|
76
|
+
case "unshare":
|
|
77
|
+
if (actions.unshare) void track(row, () => actions.unshare!(row)).then(notice => notify(`file-${row.key}`, notice.status, notice.title, notice.description), failed(row, "stop sharing"));
|
|
78
|
+
break;
|
|
79
|
+
case "rename":
|
|
80
|
+
setRenaming(row);
|
|
81
|
+
break;
|
|
82
|
+
case "replace":
|
|
83
|
+
setReplacing(row);
|
|
84
|
+
replaceInput.current?.click();
|
|
85
|
+
break;
|
|
86
|
+
case "delete":
|
|
87
|
+
setRemoving(row);
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return <RailSection title={title} action={action}>
|
|
93
|
+
<ul className="cr-list">{rows.map(row => {
|
|
94
|
+
const offered: Array<{ id: FileAction; label: string; icon: typeof Download }> = [
|
|
95
|
+
{ id: "download", label: "Download", icon: Download },
|
|
96
|
+
...(actions.share ? [{ id: "share" as const, label: row.ownLink ? "Copy share link" : "Create share link", icon: Link2 }] : []),
|
|
97
|
+
...(actions.unshare && row.shared ? [{ id: "unshare" as const, label: "Stop sharing", icon: Link2Off }] : []),
|
|
98
|
+
...(row.attachment && actions.rename ? [{ id: "rename" as const, label: "Rename", icon: Pencil }] : []),
|
|
99
|
+
...(row.attachment && actions.replace ? [{ id: "replace" as const, label: "Replace", icon: Replace }] : []),
|
|
100
|
+
...(actions.remove ? [{ id: "delete" as const, label: "Delete", icon: Trash2 }] : []),
|
|
101
|
+
];
|
|
102
|
+
return <li key={row.key} className="cr-file-row">
|
|
103
|
+
<Tooltip isOpen={hoveredFile === row.key} onOpenChange={open => setHoveredFile(open ? row.key : null)}><Button onHoverStart={() => setHoveredFile(row.key)} onHoverEnd={() => setHoveredFile(null)} onFocus={() => setHoveredFile(row.key)} onBlur={() => setHoveredFile(null)} variant="ghost" className="cr-file" aria-label={`Preview ${row.name}`} onPress={() => onOpen(row)}>
|
|
104
|
+
<FileIcon row={row} /><span>{row.name}</span>
|
|
105
|
+
</Button><Tooltip.Content placement="left" className="cr-file-tooltip">
|
|
106
|
+
<FileFacts name={row.name} sizeBytes={row.sizeBytes} modifiedAt={row.modifiedAt}>
|
|
107
|
+
{row.publishedBy && <span title={row.publishedBy}>Published by Work {row.publishedBy.slice(0, 8)}</span>}
|
|
108
|
+
{row.shared && <span>Shared by link</span>}
|
|
109
|
+
</FileFacts>
|
|
110
|
+
</Tooltip.Content></Tooltip>
|
|
111
|
+
<Dropdown>
|
|
112
|
+
<Tooltip><Button isIconOnly size="sm" variant="ghost" className="cr-file-more" aria-label={`Actions for ${row.name}`} isPending={busy.includes(row.key)}><MoreHorizontal size={14} /></Button><Tooltip.Content>More</Tooltip.Content></Tooltip>
|
|
113
|
+
<Dropdown.Popover placement="bottom end"><Dropdown.Menu aria-label={`Actions for ${row.name}`} onAction={id => run(row, String(id) as FileAction)}>
|
|
114
|
+
{offered.map(({ id, label, icon: Icon }) => <Dropdown.Item key={id} id={id} textValue={label} variant={id === "delete" ? "danger" : undefined}><Icon size={15} aria-hidden="true" /><Label>{label}</Label></Dropdown.Item>)}
|
|
115
|
+
</Dropdown.Menu></Dropdown.Popover>
|
|
116
|
+
</Dropdown>
|
|
117
|
+
</li>;
|
|
118
|
+
})}</ul>
|
|
119
|
+
<input ref={replaceInput} type="file" hidden aria-label="Choose replacement file" onChange={event => {
|
|
120
|
+
const chosen = event.target.files?.[0];
|
|
121
|
+
event.target.value = "";
|
|
122
|
+
const target = replacing;
|
|
123
|
+
setReplacing(null);
|
|
124
|
+
if (!chosen || !target || !actions.replace) return;
|
|
125
|
+
// The new content keeps the Attachment's name and share links.
|
|
126
|
+
const named = new File([chosen], target.name, { type: chosen.type });
|
|
127
|
+
track(target, () => actions.replace!(target, named)).then(() => done(target, `Replaced ${target.name}`), failed(target, "replace"));
|
|
128
|
+
}} />
|
|
129
|
+
<ConfirmDialog isOpen={removing !== null} onOpenChange={open => { if (!open) setRemoving(null); }} title={`Delete ${removing?.name ?? "file"}?`} confirmLabel="Delete" onConfirm={() => {
|
|
130
|
+
const target = removing;
|
|
131
|
+
if (target && actions.remove) track(target, () => actions.remove!(target)).then(() => done(target, `Deleted ${target.name}`), failed(target, "delete"));
|
|
132
|
+
setRemoving(null);
|
|
133
|
+
}}>
|
|
134
|
+
<p>{actions.removeNote}</p>
|
|
135
|
+
</ConfirmDialog>
|
|
136
|
+
{renaming && actions.rename && <RenameDialog key={renaming.key} row={renaming} rename={newName => actions.rename!(renaming, newName)} onClose={() => setRenaming(null)} />}
|
|
137
|
+
</RailSection>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// AttachmentConflictDialog asks before an upload replaces the Attachment of
|
|
141
|
+
// the same name; nothing is replaced unless the person confirms.
|
|
142
|
+
export function AttachmentConflictDialog({ conflict, onClose, onReplace }: { conflict: File | null; onClose: () => void; onReplace: (file: File) => void }) {
|
|
143
|
+
return <ConfirmDialog isOpen={conflict !== null} onOpenChange={open => { if (!open) onClose(); }} title={`Replace ${conflict?.name ?? "attachment"}?`} confirmLabel="Replace" onConfirm={() => { if (conflict) onReplace(conflict); onClose(); }}>
|
|
144
|
+
<p>An attachment with this name already exists. Replacing it keeps its share links; rename your file instead to keep both.</p>
|
|
145
|
+
</ConfirmDialog>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function RenameDialog({ row, rename, onClose }: { row: FileRow; rename: (newName: string) => Promise<unknown>; onClose: () => void }) {
|
|
149
|
+
const [name, setName] = useState(row.name);
|
|
150
|
+
const [pending, setPending] = useState(false);
|
|
151
|
+
const [error, setError] = useState<Error | null>(null);
|
|
152
|
+
const trimmed = name.trim();
|
|
153
|
+
const submit = () => {
|
|
154
|
+
if (!trimmed || trimmed === row.name || pending) return;
|
|
155
|
+
setPending(true); setError(null);
|
|
156
|
+
rename(trimmed).then(onClose, (cause: unknown) => setError(cause instanceof Error ? cause : new Error(String(cause)))).finally(() => setPending(false));
|
|
157
|
+
};
|
|
158
|
+
return <Modal><Modal.Backdrop isOpen onOpenChange={open => { if (!open && !pending) onClose(); }} isDismissable={!pending}><Modal.Container placement="center" size="sm"><Modal.Dialog>
|
|
159
|
+
<Modal.Header><Modal.Heading>Rename {row.name}</Modal.Heading></Modal.Header>
|
|
160
|
+
<Modal.Body>
|
|
161
|
+
<TextField value={name} onChange={setName} isInvalid={Boolean(error)} autoFocus>
|
|
162
|
+
<Label>Name</Label>
|
|
163
|
+
<Input onKeyDown={event => { if (event.key === "Enter") submit(); }} />
|
|
164
|
+
</TextField>
|
|
165
|
+
{error && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>{renameErrorMessage(error)}</Alert.Description></Alert.Content></Alert>}
|
|
166
|
+
</Modal.Body>
|
|
167
|
+
<Modal.Footer><Button variant="secondary" isDisabled={pending} onPress={onClose}>Cancel</Button><Button isPending={pending} isDisabled={!trimmed || trimmed === row.name} onPress={submit}>Rename</Button></Modal.Footer>
|
|
168
|
+
</Modal.Dialog></Modal.Container></Modal.Backdrop></Modal>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function FileIcon({ row }: { row: FileRow }) {
|
|
172
|
+
const kind = row.contentType === "application/pdf" ? "pdf" : row.contentType.startsWith("image/") ? "image" : row.name.endsWith(".json") ? "code" : "document";
|
|
173
|
+
const Icon = kind === "image" ? FileImage : kind === "code" ? FileCode2 : FileText;
|
|
174
|
+
return <Icon size={15} className="cr-file-icon" data-kind={kind} aria-hidden="true" />;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function renameErrorMessage(error: Error) {
|
|
178
|
+
if (error instanceof ConnectError && error.code === Code.AlreadyExists) return "Another attachment already has this name.";
|
|
179
|
+
if (error instanceof ConnectError && error.code === Code.InvalidArgument) return "Use one file name without slashes.";
|
|
180
|
+
return error.message;
|
|
181
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { code } from "@streamdown/code";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
import type { DiagramPlugin as OfficialMermaidPlugin } from "@streamdown/mermaid";
|
|
4
|
+
import { Block, defaultRehypePlugins, defaultRemarkPlugins, defaultUrlTransform, Streamdown, type Components, type DiagramPlugin } from "streamdown";
|
|
5
|
+
|
|
6
|
+
// Streamdown's official Mermaid plugin imports mermaid eagerly; this wrapper
|
|
7
|
+
// keeps that (large) module out of the first load until a diagram renders.
|
|
8
|
+
// Each instance carries the configuration Streamdown gives it.
|
|
9
|
+
type MermaidConfig = Parameters<OfficialMermaidPlugin["getMermaid"]>[0];
|
|
10
|
+
const mermaid: DiagramPlugin = {
|
|
11
|
+
name: "mermaid", type: "diagram", language: "mermaid",
|
|
12
|
+
getMermaid(initial) {
|
|
13
|
+
let config = initial as MermaidConfig;
|
|
14
|
+
return {
|
|
15
|
+
initialize(next) { config = next as MermaidConfig; },
|
|
16
|
+
async render(id, source) {
|
|
17
|
+
const { mermaid: plugin } = await import("@streamdown/mermaid");
|
|
18
|
+
return plugin.getMermaid(config).render(id, source);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const plugins = { code, mermaid };
|
|
25
|
+
const controls = { code: { copy: true, download: false }, table: false, mermaid: { copy: true, download: false, fullscreen: true, panZoom: false } };
|
|
26
|
+
const components: Components = {
|
|
27
|
+
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
|
28
|
+
// HAST supplies defaultChecked; controlled state also follows edits to a task.
|
|
29
|
+
input: ({ checked, defaultChecked, disabled, type }) => (
|
|
30
|
+
<input type={type} checked={checked ?? defaultChecked ?? false} disabled={disabled} readOnly />
|
|
31
|
+
),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Inline Markdown reads within a line of surrounding content, such as a cited
|
|
35
|
+
// statement: paragraphs do not break the line, and without links it can sit
|
|
36
|
+
// inside a control. It renders one block without Streamdown's block root, so
|
|
37
|
+
// it is phrasing content that inherits the surrounding type.
|
|
38
|
+
// Streamdown's own code and link components bring block behaviour (copy
|
|
39
|
+
// controls, link-safety modal), so inline mirrors only their markup and hooks;
|
|
40
|
+
// the shared app-markdown styles then apply alike.
|
|
41
|
+
const inlineComponents: Components = {
|
|
42
|
+
...components,
|
|
43
|
+
p: ({ children }) => <>{children}{" "}</>,
|
|
44
|
+
code: ({ children }) => <code className="rounded px-1.5 py-0.5 font-mono" data-streamdown="inline-code">{children}</code>,
|
|
45
|
+
a: ({ children, href }) => <a className="font-medium underline" data-streamdown="link" href={href} target="_blank" rel="noopener noreferrer">{children}</a>,
|
|
46
|
+
};
|
|
47
|
+
const inlineTextComponents: Components = { ...inlineComponents, a: ({ children }) => <>{children}</> };
|
|
48
|
+
// A preview reads as one run of text, such as a clamped summary: blocks become
|
|
49
|
+
// phrasing, so headings and lists keep their words without their structure.
|
|
50
|
+
const previewComponents: Components = {
|
|
51
|
+
...inlineTextComponents,
|
|
52
|
+
h1: ({ children }) => <>{children}{" "}</>, h2: ({ children }) => <>{children}{" "}</>, h3: ({ children }) => <>{children}{" "}</>,
|
|
53
|
+
h4: ({ children }) => <>{children}{" "}</>, h5: ({ children }) => <>{children}{" "}</>, h6: ({ children }) => <>{children}{" "}</>,
|
|
54
|
+
ul: ({ children }) => <>{children}</>, ol: ({ children }) => <>{children}</>, li: ({ children }) => <>{children}{" "}</>,
|
|
55
|
+
blockquote: ({ children }) => <>{children}</>, pre: ({ children }) => <>{children}{" "}</>, hr: () => null,
|
|
56
|
+
table: () => null, img: () => null,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// The same sanitization and URL policy Streamdown applies to its blocks.
|
|
60
|
+
const remarkPlugins = Object.values(defaultRemarkPlugins);
|
|
61
|
+
const rehypePlugins = Object.values(defaultRehypePlugins);
|
|
62
|
+
|
|
63
|
+
// Block is Streamdown's per-block renderer and its props are streaming
|
|
64
|
+
// internals: recheck them, and the markdown tests, when upgrading Streamdown.
|
|
65
|
+
export function InlineMarkdown({ children, className = "", links = true }: { children: string; className?: string; links?: boolean }) {
|
|
66
|
+
return (
|
|
67
|
+
<span className={`app-markdown-inline ${className}`} dir="auto">
|
|
68
|
+
<Block
|
|
69
|
+
content={children}
|
|
70
|
+
index={0}
|
|
71
|
+
isIncomplete={false}
|
|
72
|
+
shouldParseIncompleteMarkdown={false}
|
|
73
|
+
shouldNormalizeHtmlIndentation={false}
|
|
74
|
+
remarkPlugins={remarkPlugins}
|
|
75
|
+
rehypePlugins={rehypePlugins}
|
|
76
|
+
urlTransform={defaultUrlTransform}
|
|
77
|
+
components={links ? inlineComponents : inlineTextComponents}
|
|
78
|
+
/>
|
|
79
|
+
</span>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Markdown flattened to one run of phrasing content, for clamped previews. */
|
|
84
|
+
export function PreviewMarkdown({ children, className = "" }: { children: string; className?: string }) {
|
|
85
|
+
return (
|
|
86
|
+
<Streamdown className={`app-markdown ${className}`} mode="static" components={previewComponents} controls={false} lineNumbers={false} linkSafety={{ enabled: false }} dir="auto">
|
|
87
|
+
{children}
|
|
88
|
+
</Streamdown>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One renderer for stored documents and incrementally arriving Agent text. */
|
|
93
|
+
export function Markdown({ children, className = "", streaming = false, showControls = true }: {
|
|
94
|
+
children: string;
|
|
95
|
+
className?: string;
|
|
96
|
+
streaming?: boolean;
|
|
97
|
+
showControls?: boolean;
|
|
98
|
+
}) {
|
|
99
|
+
return (
|
|
100
|
+
<Streamdown
|
|
101
|
+
className={`app-markdown ${className}`}
|
|
102
|
+
mode={streaming ? "streaming" : "static"}
|
|
103
|
+
parseIncompleteMarkdown={streaming}
|
|
104
|
+
plugins={plugins}
|
|
105
|
+
components={components}
|
|
106
|
+
controls={showControls ? controls : false}
|
|
107
|
+
lineNumbers={false}
|
|
108
|
+
linkSafety={{ enabled: false }}
|
|
109
|
+
// A table grows to its full height in the reading flow; Streamdown's
|
|
110
|
+
// default 300px cap would nest a second scroller inside it.
|
|
111
|
+
tableMaxHeight={0}
|
|
112
|
+
dir="auto"
|
|
113
|
+
>
|
|
114
|
+
{children}
|
|
115
|
+
</Streamdown>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The longest backtick run decides the fence, so source never closes it early. */
|
|
120
|
+
function fenced(source: string, info: string): string {
|
|
121
|
+
let longest = 0;
|
|
122
|
+
for (const match of source.matchAll(/`+/g)) longest = Math.max(longest, match[0].length);
|
|
123
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
124
|
+
return `${fence}${info}\n${source}${source.endsWith("\n") ? "" : "\n"}${fence}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A source listing in the same highlighting as Markdown code blocks, numbered
|
|
129
|
+
* from `startLine` by logical line. `language` is a Shiki language or "text".
|
|
130
|
+
* The listing is the whole reading, so it grows to its full height; 0 lifts
|
|
131
|
+
* Streamdown's default 400px cap and its inner scroll.
|
|
132
|
+
*/
|
|
133
|
+
export function CodeListing({ code: source, language, startLine = 1 }: { code: string; language: string; startLine?: number }) {
|
|
134
|
+
const lastLine = startLine + source.split("\n").length - 1;
|
|
135
|
+
return (
|
|
136
|
+
<div className="app-code-listing" style={{ "--line-gutter": `${String(lastLine).length}ch` } as CSSProperties}>
|
|
137
|
+
<Streamdown className="app-markdown" mode="static" plugins={{ code }} components={components}
|
|
138
|
+
controls={{ code: { copy: true, download: false } }} lineNumbers linkSafety={{ enabled: false }}
|
|
139
|
+
codeBlockMaxHeight={0}>
|
|
140
|
+
{fenced(source, `${language} startLine=${startLine}`)}
|
|
141
|
+
</Streamdown>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Alert, Button, Card, Dropdown, Label, Switch, Tooltip } from "@heroui/react";
|
|
3
|
+
import type { Connector } from "@talgent/workbench-contracts/marketplace/public/v1/marketplace_pb";
|
|
4
|
+
import { ChevronRight, KeyRound, Lock, MoreHorizontal, Plus } from "lucide-react";
|
|
5
|
+
import { Link, useLocation, useNavigate } from "react-router-dom";
|
|
6
|
+
import { installStatusLabel, pluginStatus, connectorStatus, useConnector, useConnectorActions, useConnectors, useInstalledPlugins, usePluginCatalog } from "../../lib/marketplace-catalog";
|
|
7
|
+
import { providerName, serviceLabel, useConnectProvider } from "../../lib/marketplace-connections";
|
|
8
|
+
import { isPlatformPlugin, pluginDisplayName, pluginPresentation } from "../../lib/marketplace-plugins";
|
|
9
|
+
import { marketplacePaths } from "../../lib/marketplace-routes";
|
|
10
|
+
import { notify } from "../../lib/notifications";
|
|
11
|
+
import { ConnectorIcon, PluginIcon, ProviderIcon } from "./marketplace-icons";
|
|
12
|
+
import { ConfirmDialog, LoadState, NothingMatches, SearchBar } from "./marketplace-shared";
|
|
13
|
+
|
|
14
|
+
// A Connector's sign-in always goes through its declared Provider; there is
|
|
15
|
+
// no manual credential entry.
|
|
16
|
+
function providerNotice(connector: Connector) {
|
|
17
|
+
if (!connector.provider) return { text: "No sign-in needed", warning: false };
|
|
18
|
+
const name = providerName(connector.provider);
|
|
19
|
+
if (connector.providerConnection === "connected") return { text: `Signs in with your ${name} account`, warning: false };
|
|
20
|
+
return { text: connector.providerConnection === "reconnect_required" ? `${name} authorization needs renewal` : `${name} isn’t connected yet`, warning: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function useConnectorCommands() {
|
|
24
|
+
const actions = useConnectorActions();
|
|
25
|
+
const connect = useConnectProvider();
|
|
26
|
+
const { pathname } = useLocation();
|
|
27
|
+
const install = (connector: Connector) => {
|
|
28
|
+
const label = serviceLabel(connector.name);
|
|
29
|
+
actions.install.mutate(connector.name, {
|
|
30
|
+
// Installation never waits for authorization; connecting is offered after.
|
|
31
|
+
onSuccess: result => {
|
|
32
|
+
const offer = result.provider && result.providerConnection !== "connected";
|
|
33
|
+
notify(`connector:${connector.name}`, "success", `${label} installed`, offer ? `Connect ${providerName(result.provider)} so Works can use it.` : "Works you start from now on can use it.", {
|
|
34
|
+
action: offer ? { label: `Connect ${providerName(result.provider)}`, onPress: () => connect.mutate({ provider: result.provider, returnTo: pathname }) } : { label: "Undo", onPress: () => actions.uninstall.mutate(result) },
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
onError: () => notify(`connector:${connector.name}`, "danger", `${label} couldn’t be installed`, "Try again."),
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
const toggle = (connector: Connector) => {
|
|
41
|
+
const enabled = connector.status !== "enabled";
|
|
42
|
+
actions.setEnabled.mutate({ connector, enabled }, {
|
|
43
|
+
onSuccess: () => notify(`connector:${connector.name}`, "default", `${serviceLabel(connector.name)} ${enabled ? "enabled" : "disabled"}`),
|
|
44
|
+
onError: () => notify(`connector:${connector.name}`, "danger", `${serviceLabel(connector.name)} couldn’t be changed`, "Refresh and try again."),
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const uninstall = (connector: Connector) => actions.uninstall.mutate(connector, {
|
|
48
|
+
onSuccess: () => notify(`connector:${connector.name}`, "default", `${serviceLabel(connector.name)} uninstalled`),
|
|
49
|
+
onError: () => notify(`connector:${connector.name}`, "danger", `${serviceLabel(connector.name)} couldn’t be uninstalled`, "Refresh and try again."),
|
|
50
|
+
});
|
|
51
|
+
return { install, toggle, uninstall, connect, pending: actions.install.isPending };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function UninstallConnectorDialog({ connector, onClose, onConfirm }: { connector: Connector | null; onClose: () => void; onConfirm: (connector: Connector) => void }) {
|
|
55
|
+
return <ConfirmDialog isOpen={connector !== null} onOpenChange={open => { if (!open) onClose(); }} title={`Uninstall ${connector ? serviceLabel(connector.name) : ""}?`} confirmLabel="Uninstall" onConfirm={() => { if (connector) onConfirm(connector); }}>
|
|
56
|
+
<p>Works you start afterwards can’t use it. Works already running keep it.{connector?.provider ? ` Your ${providerName(connector.provider)} Provider stays connected.` : ""} Plugins aren’t affected.</p>
|
|
57
|
+
</ConfirmDialog>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function ConnectorList({ category }: { category?: string }) {
|
|
61
|
+
const connectors = useConnectors();
|
|
62
|
+
const commands = useConnectorCommands();
|
|
63
|
+
const navigate = useNavigate();
|
|
64
|
+
const [query, setQuery] = useState("");
|
|
65
|
+
const [removing, setRemoving] = useState<Connector | null>(null);
|
|
66
|
+
const rows = (connectors.data ?? []).filter(item => (!category || categoryOf(item) === category) && `${serviceLabel(item.name)} ${item.description}`.toLowerCase().includes(query.trim().toLowerCase()));
|
|
67
|
+
const groups = [...new Set(rows.map(categoryOf))].sort((a, b) => a === "Other" ? 1 : b === "Other" ? -1 : a.localeCompare(b)).map(name => ({ name, items: rows.filter(item => categoryOf(item) === name) }));
|
|
68
|
+
const row = (connector: Connector) => {
|
|
69
|
+
const status = connectorStatus(connector), notice = providerNotice(connector), installed = status === "enabled" || status === "disabled";
|
|
70
|
+
const label = serviceLabel(connector.name);
|
|
71
|
+
return <li key={connector.name} className="mk-row" data-off={status === "disabled" || undefined}>
|
|
72
|
+
<Link className="mk-row-open" to={marketplacePaths.connector(connector.name)}>
|
|
73
|
+
<ConnectorIcon name={connector.name} />
|
|
74
|
+
<span className="mk-row-text"><strong>{label}</strong><span className={installed && notice.warning ? "text-warning" : ""}>{status === "disabled" ? "Disabled" : installed && notice.warning ? notice.text : connector.description}</span></span>
|
|
75
|
+
</Link>
|
|
76
|
+
{connector.required ? <Tooltip><Tooltip.Trigger aria-label="Always enabled" className="mk-row-lock"><Lock size={16} /></Tooltip.Trigger><Tooltip.Content>Always enabled</Tooltip.Content></Tooltip>
|
|
77
|
+
: installed ? <Dropdown><Button size="sm" variant="ghost" isIconOnly aria-label={`${label} options`}><MoreHorizontal size={18} /></Button>
|
|
78
|
+
<Dropdown.Popover placement="bottom end"><Dropdown.Menu aria-label={`${label} options`} onAction={key => {
|
|
79
|
+
if (key === "open") navigate(marketplacePaths.connector(connector.name));
|
|
80
|
+
if (key === "toggle") commands.toggle(connector);
|
|
81
|
+
if (key === "uninstall") setRemoving(connector);
|
|
82
|
+
}}>
|
|
83
|
+
<Dropdown.Item id="open" textValue="Open"><Label>Open</Label></Dropdown.Item>
|
|
84
|
+
<Dropdown.Item id="toggle" textValue={status === "enabled" ? "Disable" : "Enable"}><Label>{status === "enabled" ? "Disable" : "Enable"}</Label></Dropdown.Item>
|
|
85
|
+
<Dropdown.Item id="uninstall" textValue="Uninstall" variant="danger"><Label>Uninstall…</Label></Dropdown.Item>
|
|
86
|
+
</Dropdown.Menu></Dropdown.Popover></Dropdown>
|
|
87
|
+
: <Button size="sm" variant="ghost" isIconOnly isPending={commands.pending} aria-label={`Install ${label}`} onPress={() => commands.install(connector)}><Plus size={20} strokeWidth={1.75} /></Button>}
|
|
88
|
+
</li>;
|
|
89
|
+
};
|
|
90
|
+
return <>
|
|
91
|
+
<SearchBar label={category ? `Search ${category.toLowerCase()} connectors` : "Search connectors"} value={query} onChange={setQuery} />
|
|
92
|
+
<LoadState noun="connectors" isPending={connectors.isPending} isError={connectors.isError} hasData={Boolean(connectors.data)} isFetching={connectors.isFetching} onRetry={() => void connectors.refetch()} />
|
|
93
|
+
{connectors.data && (groups.length === 0 ? (query ? <NothingMatches onClear={() => setQuery("")} /> : <div className="mk-empty"><p><strong>No connectors yet</strong></p></div>)
|
|
94
|
+
: groups.map(group => <section key={group.name} className="mk-section" aria-label={group.name}>
|
|
95
|
+
{!category && <Link className="mk-section-head" to={marketplacePaths.connectorCategory(group.name)}>{group.name}<ChevronRight size={18} aria-hidden="true" /></Link>}
|
|
96
|
+
<ul className="mk-rows">{(category ? group.items : group.items.slice(0, 6)).map(row)}</ul>
|
|
97
|
+
</section>))}
|
|
98
|
+
<UninstallConnectorDialog connector={removing} onClose={() => setRemoving(null)} onConfirm={commands.uninstall} />
|
|
99
|
+
</>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const categoryOf = (connector: Connector) => connector.category ? pluginDisplayName(connector.category) : "Other";
|
|
103
|
+
|
|
104
|
+
export function ConnectorPage({ name }: { name: string }) {
|
|
105
|
+
const detail = useConnector(name);
|
|
106
|
+
const commands = useConnectorCommands();
|
|
107
|
+
const catalog = usePluginCatalog();
|
|
108
|
+
const installedPlugins = useInstalledPlugins();
|
|
109
|
+
const { pathname } = useLocation();
|
|
110
|
+
const [confirm, setConfirm] = useState(false);
|
|
111
|
+
const connector = detail.data?.connector;
|
|
112
|
+
if (!connector) return <LoadState noun="connector" isPending={detail.isPending} isError={detail.isError} hasData={false} isFetching={detail.isFetching} onRetry={() => void detail.refetch()} />;
|
|
113
|
+
const label = serviceLabel(connector.name), status = connectorStatus(connector), notice = providerNotice(connector);
|
|
114
|
+
const installed = status === "enabled" || status === "disabled";
|
|
115
|
+
const releases = new Map((catalog.data ?? []).map(plugin => [plugin.slug, plugin]));
|
|
116
|
+
const connect = () => commands.connect.mutate({ provider: connector.provider, returnTo: pathname });
|
|
117
|
+
const connectLabel = connector.providerConnection === "reconnect_required" ? "Reconnect" : "Connect";
|
|
118
|
+
return <div className="mk-detail">
|
|
119
|
+
<header className="mk-detail-head">
|
|
120
|
+
<ConnectorIcon name={connector.name} size={64} />
|
|
121
|
+
<div className="mk-detail-title"><h2>{label}</h2><p className="mk-meta">{connector.category && <span>{categoryOf(connector)}</span>}<span>MCP connector</span></p></div>
|
|
122
|
+
<div className="mk-detail-actions">
|
|
123
|
+
{connector.required ? <span className="mk-muted mk-inline"><Lock size={15} aria-hidden="true" />Always enabled</span>
|
|
124
|
+
: !installed ? <Button variant="primary" isPending={commands.pending} onPress={() => commands.install(connector)}>Install</Button>
|
|
125
|
+
: <>
|
|
126
|
+
<Switch isSelected={status === "enabled"} onChange={() => commands.toggle(connector)} aria-label={`Enable ${label}`}><Switch.Control><Switch.Thumb /></Switch.Control></Switch>
|
|
127
|
+
<Dropdown><Button size="sm" variant="ghost" isIconOnly aria-label={`More actions for ${label}`}><MoreHorizontal size={18} /></Button>
|
|
128
|
+
<Dropdown.Popover placement="bottom end"><Dropdown.Menu aria-label="More actions" onAction={key => { if (key === "uninstall") setConfirm(true); }}>
|
|
129
|
+
<Dropdown.Item id="uninstall" textValue="Uninstall" variant="danger"><Label>Uninstall…</Label></Dropdown.Item>
|
|
130
|
+
</Dropdown.Menu></Dropdown.Popover></Dropdown>
|
|
131
|
+
</>}
|
|
132
|
+
</div>
|
|
133
|
+
</header>
|
|
134
|
+
<UninstallConnectorDialog connector={confirm ? connector : null} onClose={() => setConfirm(false)} onConfirm={commands.uninstall} />
|
|
135
|
+
{installed && notice.warning && <Alert status="warning"><Alert.Indicator /><Alert.Content><Alert.Title>{notice.text}</Alert.Title>
|
|
136
|
+
<Alert.Description>Works start without {label} access until you {connectLabel.toLowerCase()} your {providerName(connector.provider)} account.</Alert.Description>
|
|
137
|
+
<Button size="sm" variant="secondary" className="mt-2" isPending={commands.connect.isPending} onPress={connect}><KeyRound size={14} />{connectLabel} {providerName(connector.provider)}</Button>
|
|
138
|
+
</Alert.Content></Alert>}
|
|
139
|
+
|
|
140
|
+
<section className="mk-section"><h3 className="mk-section-title">About</h3>
|
|
141
|
+
<Card className="mk-group mk-about mk-about-single"><p className="mk-about-text">{connector.description}</p></Card>
|
|
142
|
+
</section>
|
|
143
|
+
|
|
144
|
+
{connector.provider && <section className="mk-section"><h3 className="mk-section-title">Provider</h3>
|
|
145
|
+
<Card className="mk-group"><ul className="mk-group-list"><li className="mk-group-link mk-group-split">
|
|
146
|
+
<Link to={marketplacePaths.provider(connector.provider)}>
|
|
147
|
+
<ProviderIcon id={connector.provider} size={32} /><span className="mk-row-text"><strong>{providerName(connector.provider)}</strong><span className={notice.warning ? "text-warning" : ""}>{connector.providerConnection === "connected" ? "Connected" : connector.providerConnection === "reconnect_required" ? "Authorization expired" : "Not connected — required to use this connector"}</span></span>
|
|
148
|
+
{!notice.warning && <ChevronRight size={16} className="mk-muted ml-auto" aria-hidden="true" />}
|
|
149
|
+
</Link>
|
|
150
|
+
{notice.warning && <Button size="sm" variant="secondary" isPending={commands.connect.isPending} onPress={connect}>{connectLabel}</Button>}
|
|
151
|
+
</li></ul></Card>
|
|
152
|
+
</section>}
|
|
153
|
+
|
|
154
|
+
{(detail.data?.plugins.length ?? 0) > 0 && <section className="mk-section">
|
|
155
|
+
<h3 className="mk-section-title">Plugins that use it <span className="mk-count">{detail.data!.plugins.length}</span></h3>
|
|
156
|
+
<Card className="mk-group"><ul className="mk-group-list">{detail.data!.plugins.map(item => {
|
|
157
|
+
const release = releases.get(item.slug);
|
|
158
|
+
return <li key={item.slug} className="mk-group-link"><Link to={marketplacePaths.plugin(item.slug)}>
|
|
159
|
+
{release ? <PluginIcon plugin={release} size={32} /> : <ConnectorIcon name={item.slug} size={32} />}
|
|
160
|
+
<span className="mk-row-text"><strong>{release ? pluginPresentation(release).name : pluginDisplayName(item.slug)}<span className="mk-kind">Plugin</span></strong><span>{installStatusLabel[pluginStatus(isPlatformPlugin(item.slug), installedPlugins.data?.get(item.slug))]}</span></span>
|
|
161
|
+
<ChevronRight size={16} className="mk-muted ml-auto" aria-hidden="true" />
|
|
162
|
+
</Link></li>;
|
|
163
|
+
})}</ul></Card>
|
|
164
|
+
</section>}
|
|
165
|
+
</div>;
|
|
166
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { BookOpen, Code2, Database, FlaskConical, Palette, Plug, Puzzle, Rocket, ShieldCheck, Sparkles, Workflow, type LucideIcon } from "lucide-react";
|
|
2
|
+
import figma from "../../assets/brand-icons/figma.svg";
|
|
3
|
+
import github from "../../assets/brand-icons/github.svg";
|
|
4
|
+
import linear from "../../assets/brand-icons/linear.svg";
|
|
5
|
+
import notion from "../../assets/brand-icons/notion.svg";
|
|
6
|
+
import sentry from "../../assets/brand-icons/sentry.svg";
|
|
7
|
+
import slack from "../../assets/brand-icons/slack.svg";
|
|
8
|
+
import { pluginPresentation, type PresentablePlugin } from "../../lib/marketplace-plugins";
|
|
9
|
+
|
|
10
|
+
// Known services use their brand mark; Talgent's own Plugins and Connectors use
|
|
11
|
+
// the product mark; every other Plugin gets a glyph tinted by its category.
|
|
12
|
+
const brands: Record<string, { url: string; color: string }> = {
|
|
13
|
+
github: { url: github, color: "#181717" }, linear: { url: linear, color: "#5E6AD2" }, notion: { url: notion, color: "#000000" },
|
|
14
|
+
slack: { url: slack, color: "#4A154B" }, figma: { url: figma, color: "#F24E1E" }, sentry: { url: sentry, color: "#362D59" },
|
|
15
|
+
talgent: { url: "/assets/brand/talgent-mark.png", color: "var(--brand-foreground)" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// A name belongs to a brand when it is the brand or a hyphenated extension of it.
|
|
19
|
+
function brandOf(name: string) {
|
|
20
|
+
const lower = name.toLowerCase();
|
|
21
|
+
return Object.keys(brands).find(brand => lower === brand || lower.startsWith(`${brand}-`) || lower.endsWith(`-${brand}`));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const categories: Record<string, { hue: string; glyph: LucideIcon }> = {
|
|
25
|
+
Development: { hue: "oklch(55% 0.09 250)", glyph: Code2 },
|
|
26
|
+
Productivity: { hue: "oklch(52% 0.09 285)", glyph: Workflow },
|
|
27
|
+
Security: { hue: "oklch(55% 0.1 20)", glyph: ShieldCheck },
|
|
28
|
+
Testing: { hue: "oklch(55% 0.09 150)", glyph: FlaskConical },
|
|
29
|
+
Database: { hue: "oklch(55% 0.08 190)", glyph: Database },
|
|
30
|
+
Deployment: { hue: "oklch(58% 0.1 45)", glyph: Rocket },
|
|
31
|
+
Design: { hue: "oklch(55% 0.11 330)", glyph: Palette },
|
|
32
|
+
Learning: { hue: "oklch(58% 0.09 75)", glyph: BookOpen },
|
|
33
|
+
Other: { hue: "oklch(55% 0 0)", glyph: Puzzle },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function Tile({ size, hue, brand, children }: { size: number; hue?: string; brand?: boolean; children: React.ReactNode }) {
|
|
37
|
+
return <span className={`mk-icon${brand ? " mk-icon-brand" : ""}`} style={{ width: size, height: size, borderRadius: Math.round(size * 0.28), ["--mk-hue" as string]: hue ?? categories.Other.hue }} aria-hidden="true">{children}</span>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function BrandMark({ id, size }: { id: string; size: number }) {
|
|
41
|
+
const brand = brands[id];
|
|
42
|
+
return <span className="mk-brand-mark" style={{ width: size, height: size, backgroundColor: brand.color, maskImage: `url("${brand.url}")`, WebkitMaskImage: `url("${brand.url}")` }} />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function PluginIcon({ plugin, size = 40 }: { plugin: PresentablePlugin; size?: number }) {
|
|
46
|
+
const brand = plugin.source === "talgent" ? "talgent" : brandOf(plugin.slug);
|
|
47
|
+
if (brand) return <Tile size={size} brand><BrandMark id={brand} size={Math.round(size * 0.5)} /></Tile>;
|
|
48
|
+
const category = categories[pluginPresentation(plugin).category] ?? { hue: categories.Other.hue, glyph: Sparkles };
|
|
49
|
+
const Glyph = category.glyph;
|
|
50
|
+
return <Tile size={size} hue={category.hue}><Glyph size={Math.round(size * 0.48)} strokeWidth={1.7} /></Tile>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ConnectorIcon({ name, size = 40 }: { name: string; size?: number }) {
|
|
54
|
+
const brand = brandOf(name);
|
|
55
|
+
return <span className="mk-icon-stack" style={{ width: size, height: size }} aria-hidden="true">
|
|
56
|
+
{brand ? <Tile size={size} brand><BrandMark id={brand} size={Math.round(size * 0.5)} /></Tile>
|
|
57
|
+
: <Tile size={size} hue="oklch(57% 0.09 70)"><span className="mk-icon-letter" style={{ fontSize: Math.round(size * 0.4) }}>{name.charAt(0).toUpperCase()}</span></Tile>}
|
|
58
|
+
{size >= 28 && <span className="mk-icon-badge"><Plug size={Math.max(10, Math.round(size * 0.26))} /></span>}
|
|
59
|
+
</span>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ProviderIcon({ id, size = 40 }: { id: string; size?: number }) {
|
|
63
|
+
const brand = brandOf(id);
|
|
64
|
+
return brand ? <Tile size={size} brand><BrandMark id={brand} size={Math.round(size * 0.5)} /></Tile> : <Tile size={size}><span className="mk-icon-letter">{id.charAt(0).toUpperCase()}</span></Tile>;
|
|
65
|
+
}
|
|
66
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Alert, AlertDialog, Button, SearchField, Skeleton } from "@heroui/react";
|
|
2
|
+
import { Bot, Plug, Sparkles } from "lucide-react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
// One hue per native component type, used on every Marketplace page.
|
|
6
|
+
const capabilityIcons = { skill: Sparkles, agent: Bot, mcp: Plug } as const;
|
|
7
|
+
export function CapIcon({ kind, size = 16 }: { kind: keyof typeof capabilityIcons; size?: number }) {
|
|
8
|
+
const Icon = capabilityIcons[kind];
|
|
9
|
+
return <Icon size={size} className={`mk-cap mk-cap-${kind}`} aria-hidden="true" />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SearchBar({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
|
13
|
+
return <div className="mk-toolbar"><SearchField aria-label={label} value={value} onChange={onChange} className="mk-search">
|
|
14
|
+
<SearchField.Group><SearchField.SearchIcon /><SearchField.Input placeholder={label} /><SearchField.ClearButton aria-label="Clear search" /></SearchField.Group>
|
|
15
|
+
</SearchField></div>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function NothingMatches({ onClear }: { onClear: () => void }) {
|
|
19
|
+
return <div className="mk-empty"><p><strong>Nothing matches</strong></p><Button size="sm" variant="secondary" onPress={onClear}>Clear search</Button></div>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Loading text stays screen-reader-only; a failed refresh keeps the last good
|
|
23
|
+
// list with a warning, and a list that never loaded offers retry in its place.
|
|
24
|
+
export function LoadState({ noun, isPending, isError, hasData, isFetching, onRetry }: { noun: string; isPending: boolean; isError: boolean; hasData: boolean; isFetching: boolean; onRetry: () => void }) {
|
|
25
|
+
if (isPending) return <div className="mk-skeletons" aria-busy="true"><p className="sr-only" role="status">Loading {noun}…</p>{[0, 1, 2].map(index => <Skeleton key={index} className="mk-skeleton" />)}</div>;
|
|
26
|
+
if (!isError) return null;
|
|
27
|
+
return <Alert status={hasData ? "warning" : "danger"}><Alert.Indicator /><Alert.Content>
|
|
28
|
+
<Alert.Title>{hasData ? `${capitalize(noun)} couldn’t be refreshed` : `${capitalize(noun)} couldn’t be loaded`}</Alert.Title>
|
|
29
|
+
{hasData && <Alert.Description>Showing what was loaded before.</Alert.Description>}
|
|
30
|
+
<Button size="sm" variant="secondary" className="mt-2" isPending={isFetching} onPress={onRetry}>Try again</Button>
|
|
31
|
+
</Alert.Content></Alert>;
|
|
32
|
+
}
|
|
33
|
+
const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
34
|
+
|
|
35
|
+
// Destructive confirmation; Cancel receives focus first.
|
|
36
|
+
export function ConfirmDialog({ isOpen, onOpenChange, title, children, confirmLabel, onConfirm }: { isOpen: boolean; onOpenChange: (open: boolean) => void; title: string; children: ReactNode; confirmLabel: string; onConfirm: () => void }) {
|
|
37
|
+
return <AlertDialog><AlertDialog.Backdrop isOpen={isOpen} onOpenChange={onOpenChange}><AlertDialog.Container><AlertDialog.Dialog className="sm:max-w-[420px]">
|
|
38
|
+
<AlertDialog.Header><AlertDialog.Icon status="danger" /><AlertDialog.Heading>{title}</AlertDialog.Heading></AlertDialog.Header>
|
|
39
|
+
<AlertDialog.Body>{children}</AlertDialog.Body>
|
|
40
|
+
<AlertDialog.Footer><Button slot="close" variant="tertiary" autoFocus>Cancel</Button><Button slot="close" variant="danger" onPress={onConfirm}>{confirmLabel}</Button></AlertDialog.Footer>
|
|
41
|
+
</AlertDialog.Dialog></AlertDialog.Container></AlertDialog.Backdrop></AlertDialog>;
|
|
42
|
+
}
|
|
43
|
+
|