@talgent/workbench-ui 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/assets/brand-icons/LICENSE.md +30 -0
- package/assets/brand-icons/README.md +5 -0
- package/assets/brand-icons/figma.svg +1 -0
- package/assets/brand-icons/github.svg +1 -0
- package/assets/brand-icons/linear.svg +1 -0
- package/assets/brand-icons/notion.svg +1 -0
- package/assets/brand-icons/sentry.svg +1 -0
- package/assets/brand-icons/slack.svg +1 -0
- package/assets/fonts/Inter-OFL.txt +93 -0
- package/assets/fonts/inter-latin.woff2 +0 -0
- package/components/agent/agent-icon.css +14 -0
- package/components/agent/agent-icon.tsx +14 -0
- package/components/analytics/chart-geometry.ts +95 -0
- package/components/analytics/dashboard-charts.tsx +136 -0
- package/components/analytics/model-usage-chart.tsx +84 -0
- package/components/analytics/token-breakdown.css +13 -0
- package/components/analytics/token-breakdown.tsx +23 -0
- package/components/analytics/token-report.tsx +57 -0
- package/components/analytics/work-usage/turn-timeline.tsx +98 -0
- package/components/analytics/work-usage/turns.ts +170 -0
- package/components/analytics/work-usage/work-status.ts +36 -0
- package/components/analytics/work-usage/work-usage-reader.tsx +122 -0
- package/components/analytics/work-usage/work-usage.css +48 -0
- package/components/auth/auth-gate.tsx +44 -0
- package/components/chat/message-input.tsx +105 -0
- package/components/composer/permission-mode-list.tsx +15 -0
- package/components/composer/prompt-bar-controls.tsx +77 -0
- package/components/composer/prompt-bar.tsx +45 -0
- package/components/composer/skill-completion.css +27 -0
- package/components/composer/skill-completion.tsx +49 -0
- package/components/composer/use-slash-completion.tsx +69 -0
- package/components/composer/work-start.css +133 -0
- package/components/feedback/toast-region.css +2 -0
- package/components/feedback/toast-region.tsx +15 -0
- package/components/files/file-facts.tsx +16 -0
- package/components/files/file-section.tsx +181 -0
- package/components/markdown.tsx +144 -0
- package/components/marketplace/connector-pages.tsx +166 -0
- package/components/marketplace/marketplace-icons.tsx +66 -0
- package/components/marketplace/marketplace-shared.tsx +43 -0
- package/components/marketplace/marketplace-shell.tsx +32 -0
- package/components/marketplace/marketplace.css +97 -0
- package/components/marketplace/parameter-fields.tsx +46 -0
- package/components/marketplace/plugin-list.tsx +84 -0
- package/components/marketplace/plugin-page.tsx +133 -0
- package/components/marketplace/provider-pages.tsx +120 -0
- package/components/preview/file-format.ts +75 -0
- package/components/preview/file-preview.tsx +153 -0
- package/components/preview/preview-verdict.ts +44 -0
- package/components/preview/preview.css +19 -0
- package/components/preview/text-preview.tsx +174 -0
- package/components/profile/provider-callback.tsx +43 -0
- package/components/profile/work-agent-appearance.tsx +109 -0
- package/components/profile/work-agent-soul.tsx +76 -0
- package/components/rail/rail-section.tsx +20 -0
- package/components/rail/rail.css +19 -0
- package/components/rail/use-expand-on-change.ts +18 -0
- package/components/session/startup-progress.css +77 -0
- package/components/session/startup-progress.tsx +103 -0
- package/components/session/work-permission-control.tsx +45 -0
- package/components/session/work-thinking-control.tsx +52 -0
- package/components/shell/account-menu.tsx +134 -0
- package/components/shell/app-shell.tsx +148 -0
- package/components/shell/core-reading-views.tsx +26 -0
- package/components/shell/core-reading.tsx +17 -0
- package/components/shell/document-title.tsx +11 -0
- package/components/shell/entry-shell.ts +39 -0
- package/components/shell/file-reading.tsx +22 -0
- package/components/shell/page-layout.tsx +7 -0
- package/components/shell/reading-context.ts +41 -0
- package/components/shell/reading-panel.css +67 -0
- package/components/shell/reading-panel.tsx +74 -0
- package/components/shell/sidebar-parts.tsx +38 -0
- package/components/work/context-rail.css +61 -0
- package/components/work/conversation-unavailable.css +6 -0
- package/components/work/conversation-unavailable.tsx +11 -0
- package/components/work/subagent-item.tsx +42 -0
- package/components/work/subagents-section.css +31 -0
- package/components/work/subagents-section.tsx +40 -0
- package/components/work/token-usage.tsx +28 -0
- package/components/work/tool-message-detail.tsx +39 -0
- package/components/work/tool-output-preview.tsx +52 -0
- package/components/work/tool-preview-reading.tsx +27 -0
- package/components/work/tool-reading-content.tsx +31 -0
- package/components/work/work-context-rail.tsx +23 -0
- package/components/work/work-conversation.tsx +207 -0
- package/components/work/work-history.tsx +115 -0
- package/components/work/work-interaction.css +38 -0
- package/components/work/work-interaction.tsx +105 -0
- package/components/work/work-message-flow.css +86 -0
- package/components/work/work-message-flow.tsx +352 -0
- package/components/work/work-progress-section.tsx +20 -0
- package/components/work/work-prompt-bar.tsx +82 -0
- package/components/work/work-question-record.tsx +72 -0
- package/components/work/work-snapshot-state.tsx +24 -0
- package/components/work/work-status-control.tsx +54 -0
- package/components/work-start/start-work-launch-state.ts +33 -0
- package/components/work-start/work-start-catalog.tsx +116 -0
- package/components/work-start/work-start-form.tsx +173 -0
- package/components/work-start/work-start-tray.css +40 -0
- package/components/work-start/work-start-tray.tsx +128 -0
- package/hooks/use-debounced.ts +11 -0
- package/hooks/use-work-conversation.ts +125 -0
- package/hooks/use-work-lifecycle-controls.ts +40 -0
- package/hooks/use-work-stream.ts +91 -0
- package/lib/account-service.ts +77 -0
- package/lib/agent-appearance-service.ts +59 -0
- package/lib/agent-icon.ts +26 -0
- package/lib/agent-soul-service.ts +48 -0
- package/lib/agent-work-service.ts +23 -0
- package/lib/client.ts +20 -0
- package/lib/computer-release.ts +30 -0
- package/lib/computer-service.ts +241 -0
- package/lib/dashboard-report.ts +38 -0
- package/lib/dashboard.ts +21 -0
- package/lib/marketplace-catalog.ts +138 -0
- package/lib/marketplace-connections.ts +98 -0
- package/lib/marketplace-plugins.ts +53 -0
- package/lib/marketplace-routes.ts +12 -0
- package/lib/model-vendors.ts +13 -0
- package/lib/name-taken-error.ts +7 -0
- package/lib/notifications.ts +37 -0
- package/lib/permission-modes.ts +25 -0
- package/lib/personal-usage-service.ts +19 -0
- package/lib/personal-work-service.ts +159 -0
- package/lib/plugin-parameters.ts +64 -0
- package/lib/preview-verdict.ts +26 -0
- package/lib/relative-time.ts +11 -0
- package/lib/session.ts +108 -0
- package/lib/share-link.ts +4 -0
- package/lib/stale-build-reload.ts +41 -0
- package/lib/start-work-request.ts +34 -0
- package/lib/startup-progress.ts +9 -0
- package/lib/subagents.ts +12 -0
- package/lib/thinking-levels.ts +2 -0
- package/lib/token-analytics.ts +168 -0
- package/lib/token-usage.ts +64 -0
- package/lib/trace-view-model.ts +292 -0
- package/lib/transport.ts +29 -0
- package/lib/use-now.ts +11 -0
- package/lib/use-work-trace.ts +175 -0
- package/lib/user-display.ts +49 -0
- package/lib/work-conversation.ts +118 -0
- package/lib/work-file-service.ts +118 -0
- package/lib/work-model.ts +38 -0
- package/lib/work-notices.ts +23 -0
- package/lib/work-progress.ts +41 -0
- package/lib/work-projection.ts +122 -0
- package/lib/work-questions.ts +18 -0
- package/lib/work-start-draft.ts +61 -0
- package/lib/work-start-loadout.ts +100 -0
- package/lib/work-start-source.ts +16 -0
- package/lib/work-state-presentation.ts +80 -0
- package/lib/work-stream-source.ts +32 -0
- package/package.json +81 -5
- package/pages/computers/computers-page.tsx +56 -0
- package/pages/computers/connect-computer-page.tsx +92 -0
- package/pages/computers/install-commands.tsx +42 -0
- package/pages/computers/remove-computer-dialog.tsx +33 -0
- package/pages/computers/rename-computer-dialog.tsx +37 -0
- package/pages/computers/room-rules-dialog.tsx +55 -0
- package/pages/login-page.css +33 -0
- package/pages/login-page.tsx +88 -0
- package/pages/login-threads.tsx +51 -0
- package/pages/marketplace-page.tsx +59 -0
- package/pages/profile-page.tsx +68 -0
- package/pages/workbench/library-page.tsx +88 -0
- package/pages/workbench/new-work-page.tsx +24 -0
- package/pages/workbench/person.tsx +13 -0
- package/pages/workbench/personal-work-context.tsx +93 -0
- package/pages/workbench/personal-work-page.tsx +100 -0
- package/pages/workbench/personal-works-page.tsx +36 -0
- package/pages/workbench/share-dialog.tsx +57 -0
- package/pages/workbench/title-dialog.tsx +35 -0
- package/pages/workbench/usage-page.tsx +52 -0
- package/public/assets/brand/talgent-lettering.png +0 -0
- package/public/assets/brand/talgent-logo-page-bg.png +0 -0
- package/public/assets/brand/talgent-logo-white-bg.png +0 -0
- package/public/assets/brand/talgent-mark-page-bg.png +0 -0
- package/public/assets/brand/talgent-mark-white-bg.png +0 -0
- package/public/assets/brand/talgent-mark.png +0 -0
- package/public/assets/product-icons/LICENSE +21 -0
- package/public/assets/product-icons/README.md +13 -0
- package/public/assets/product-icons/anthropic.svg +1 -0
- package/public/assets/product-icons/deepseek.svg +1 -0
- package/public/assets/product-icons/glm.svg +1 -0
- package/public/assets/product-icons/kimi.svg +1 -0
- package/public/assets/product-icons/linear.svg +1 -0
- package/public/assets/product-icons/minimax.svg +1 -0
- package/public/assets/product-icons/notion.svg +1 -0
- package/public/assets/product-icons/openai.svg +1 -0
- package/public/assets/product-icons/qwen.svg +1 -0
- package/styles/base.css +95 -0
- package/styles/fonts.css +14 -0
- package/styles/markdown.css +79 -0
- package/styles/uber-theme.css +96 -0
- package/styles/work-composer.css +222 -0
- package/styles/work-stream.css +343 -0
- package/styles/workbench.css +144 -0
- package/styles/workspace.css +260 -0
- package/test-support/backend.ts +67 -0
- package/test-support/plugin-summary.ts +8 -0
- package/test-support/setup.ts +59 -0
- package/vite-preset.ts +85 -0
package/lib/client.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createClient } from "@connectrpc/connect";
|
|
2
|
+
import { AgentWorkService } from "@talgent/workbench-contracts/agent/public/v1/work_pb";
|
|
3
|
+
import { PersonalWorkService } from "@talgent/workbench-contracts/agent/public/v1/personal_work_pb";
|
|
4
|
+
import { WorkFileService } from "@talgent/workbench-contracts/agent/public/v1/work_file_pb";
|
|
5
|
+
import { StreamGatewayService } from "@talgent/workbench-contracts/streamgateway/public/v1/streamgateway_pb";
|
|
6
|
+
import { TraceQueryService } from "@talgent/workbench-contracts/trace/public/v1/trace_pb";
|
|
7
|
+
import { MarketplaceService } from "@talgent/workbench-contracts/marketplace/public/v1/marketplace_pb";
|
|
8
|
+
import { SessionService } from "@talgent/workbench-contracts/account/public/v1/session_pb";
|
|
9
|
+
import { ComputerService } from "@talgent/workbench-contracts/computer/public/v1/computer_pb";
|
|
10
|
+
import { transport, streamTransport, traceTransport } from "./transport";
|
|
11
|
+
|
|
12
|
+
// The Agent Workbench core's clients.
|
|
13
|
+
export const agentWorkClient = createClient(AgentWorkService, transport);
|
|
14
|
+
export const personalWorkClient = createClient(PersonalWorkService, transport);
|
|
15
|
+
export const workFileClient = createClient(WorkFileService, transport);
|
|
16
|
+
export const streamClient = createClient(StreamGatewayService, streamTransport);
|
|
17
|
+
export const traceClient = createClient(TraceQueryService, traceTransport);
|
|
18
|
+
export const marketplaceClient = createClient(MarketplaceService, transport);
|
|
19
|
+
export const sessionClient = createClient(SessionService, transport);
|
|
20
|
+
export const computerClient = createClient(ComputerService, transport);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
|
|
3
|
+
// The platform release this deployment runs, as Computer's machine service
|
|
4
|
+
// answers it (GET /computer/v1/release): Talgent Computer is published to npm at that
|
|
5
|
+
// version. Empty when the deployment is no release.
|
|
6
|
+
|
|
7
|
+
const release = /^\d+\.\d+\.\d+$/;
|
|
8
|
+
|
|
9
|
+
export async function fetchComputerRelease(): Promise<string> {
|
|
10
|
+
const response = await fetch("/computer/v1/release", { headers: { Accept: "application/json" } });
|
|
11
|
+
if (!response.ok) throw new Error(`the release answered ${response.status}`);
|
|
12
|
+
const body: unknown = await response.json();
|
|
13
|
+
const version = typeof body === "object" && body !== null && "version" in body ? body.version : undefined;
|
|
14
|
+
return typeof version === "string" && release.test(version) ? version : "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useComputerRelease() {
|
|
18
|
+
return useQuery({ queryKey: ["computer-release"], queryFn: fetchComputerRelease, staleTime: 5 * 60_000 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The commands that install Talgent Computer on a machine of any platform,
|
|
22
|
+
* at the release this deployment runs, register it with this entry and keep
|
|
23
|
+
* it running as the person's background service. */
|
|
24
|
+
export function installCommands(origin: string, version: string): string[] {
|
|
25
|
+
return [
|
|
26
|
+
`npm install -g @talgent/computer${version === "" ? "" : `@${version}`}`,
|
|
27
|
+
`talgent-computer register --server ${origin}`,
|
|
28
|
+
"talgent-computer service install",
|
|
29
|
+
];
|
|
30
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { AdmissionPolicy as AdmissionPolicyMessage, UsableComputerKind, type ManagedComputer as ManagedComputerMessage } from "@talgent/workbench-contracts/computer/public/v1/computer_pb";
|
|
4
|
+
import { useCurrentUser } from "./account-service";
|
|
5
|
+
import { computerClient } from "./client";
|
|
6
|
+
import { timeAgo } from "./relative-time";
|
|
7
|
+
|
|
8
|
+
// The person's own Managed Computers (Computer's ComputerService): the
|
|
9
|
+
// machines they registered, whether the platform reaches each now, naming
|
|
10
|
+
// them, and approving a machine's registration. A Cloud never appears here.
|
|
11
|
+
|
|
12
|
+
export type Presence = { kind: "online" } | { kind: "offline"; since: Date | null } | { kind: "never" };
|
|
13
|
+
|
|
14
|
+
/** What a full Computer answers a run: reject it, or queue it. */
|
|
15
|
+
export type AdmissionPolicy = "reject" | "queue";
|
|
16
|
+
|
|
17
|
+
export type ManagedComputer = {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
presence: Presence;
|
|
21
|
+
talgentComputerVersion: string;
|
|
22
|
+
claudeCodeVersion: string;
|
|
23
|
+
/** How many Works it runs at once. */
|
|
24
|
+
capacity: number;
|
|
25
|
+
policy: AdmissionPolicy;
|
|
26
|
+
/** How many Works it runs now, and how many wait in its line. */
|
|
27
|
+
running: number;
|
|
28
|
+
waiting: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function presenceOf(presence: ManagedComputerMessage["presence"]): Presence {
|
|
32
|
+
if (presence?.reachable) return { kind: "online" };
|
|
33
|
+
if (!presence || presence.neverConnected) return { kind: "never" };
|
|
34
|
+
const since = Number(presence.unreachableSinceUnixMs);
|
|
35
|
+
return { kind: "offline", since: since > 0 ? new Date(since) : null };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function managedComputer(computer: ManagedComputerMessage): ManagedComputer {
|
|
39
|
+
return {
|
|
40
|
+
id: computer.id,
|
|
41
|
+
name: computer.name,
|
|
42
|
+
presence: presenceOf(computer.presence),
|
|
43
|
+
talgentComputerVersion: computer.talgentComputerVersion,
|
|
44
|
+
claudeCodeVersion: computer.claudeCodeVersion,
|
|
45
|
+
capacity: Number(computer.capacity),
|
|
46
|
+
policy: computer.admissionPolicy === AdmissionPolicyMessage.QUEUE ? "queue" : "reject",
|
|
47
|
+
running: Number(computer.running),
|
|
48
|
+
waiting: Number(computer.waiting),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** How a Computer's room reads in the list ("1 of 2 running · 1 waiting"). */
|
|
53
|
+
export function roomLabel(computer: Pick<ManagedComputer, "capacity" | "running" | "waiting">): string {
|
|
54
|
+
const running = `${computer.running} of ${computer.capacity} running`;
|
|
55
|
+
return computer.waiting > 0 ? `${running} · ${computer.waiting} waiting` : running;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const maxCapacity = 32;
|
|
59
|
+
|
|
60
|
+
/** Why a Capacity can't be saved, or "" when it can. */
|
|
61
|
+
export function capacityProblem(capacity: number, running: number): string {
|
|
62
|
+
if (!Number.isInteger(capacity) || capacity < 1 || capacity > maxCapacity) return `Choose 1 to ${maxCapacity} Works.`;
|
|
63
|
+
if (capacity < running) return `It runs ${running} Works now. Choose at least ${running}.`;
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** How a Computer's Presence reads in the list. */
|
|
68
|
+
export function presenceLabel(presence: Presence, now = Date.now()): string {
|
|
69
|
+
switch (presence.kind) {
|
|
70
|
+
case "online": return "Online";
|
|
71
|
+
case "never": return "Never connected";
|
|
72
|
+
case "offline": return presence.since ? `Offline since ${timeAgo(presence.since, now)}` : "Offline";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const maxComputerName = 64;
|
|
77
|
+
|
|
78
|
+
/** Why a name can't be saved, or "" when it can. The server trims too. */
|
|
79
|
+
export function computerNameProblem(name: string): string {
|
|
80
|
+
const trimmed = name.trim();
|
|
81
|
+
if (!trimmed) return "Enter a name.";
|
|
82
|
+
if (Array.from(trimmed).length > maxComputerName) return `Use at most ${maxComputerName} characters.`;
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const computersKey = ["computers"] as const;
|
|
87
|
+
|
|
88
|
+
/** The person's Managed Computers, newest first, polled so Presence moves
|
|
89
|
+
* without a reload. */
|
|
90
|
+
export function useComputers() {
|
|
91
|
+
const userId = useCurrentUser().data?.user?.id;
|
|
92
|
+
return useQuery({
|
|
93
|
+
queryKey: [...computersKey, userId ?? ""],
|
|
94
|
+
queryFn: async ({ signal }) => (await computerClient.listComputers({}, { signal })).computers.map(managedComputer),
|
|
95
|
+
enabled: Boolean(userId),
|
|
96
|
+
retry: false,
|
|
97
|
+
refetchInterval: 10_000,
|
|
98
|
+
refetchOnWindowFocus: false,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A Computer a person may start a Personal Work on: a Cloud, or one of
|
|
103
|
+
* their Managed Computers, which takes a start only while reachable. sandbox
|
|
104
|
+
* is whether a Managed Computer's machine reports it can provide Claude
|
|
105
|
+
* Code's sandbox. */
|
|
106
|
+
export type UsableComputer = { id: string; name: string; managed: boolean; reachable: boolean; sandbox: boolean };
|
|
107
|
+
|
|
108
|
+
/** Every Cloud, then the person's Managed Computers; the first is the default.
|
|
109
|
+
* Polled so a Managed Computer going offline shows before a start is refused. */
|
|
110
|
+
export function useUsableComputers(enabled = true) {
|
|
111
|
+
const userId = useCurrentUser().data?.user?.id;
|
|
112
|
+
return useQuery({
|
|
113
|
+
queryKey: ["usable-computers", userId ?? ""],
|
|
114
|
+
queryFn: async ({ signal }): Promise<UsableComputer[]> => (await computerClient.listUsableComputers({}, { signal })).computers.map(computer => ({
|
|
115
|
+
id: computer.id,
|
|
116
|
+
name: computer.name,
|
|
117
|
+
managed: computer.kind === UsableComputerKind.MANAGED,
|
|
118
|
+
reachable: computer.kind !== UsableComputerKind.MANAGED || computer.reachable,
|
|
119
|
+
sandbox: computer.kind === UsableComputerKind.MANAGED && computer.claudeCodeSandbox,
|
|
120
|
+
})),
|
|
121
|
+
enabled: enabled && Boolean(userId),
|
|
122
|
+
retry: false,
|
|
123
|
+
refetchInterval: 10_000,
|
|
124
|
+
refetchOnWindowFocus: false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The Computer a Work runs on, as its reader sees it: named when it is one
|
|
129
|
+
* the reader may use; another person's Managed Computer is shown only as
|
|
130
|
+
* managed. Absent until the Work names its Computer. */
|
|
131
|
+
export type WorkComputer = { name?: string; managed: boolean };
|
|
132
|
+
|
|
133
|
+
export function useWorkComputer(computerId: string | undefined, managed: boolean): WorkComputer | undefined {
|
|
134
|
+
const usable = useUsableComputers(Boolean(computerId));
|
|
135
|
+
if (!computerId) return undefined;
|
|
136
|
+
const known = usable.data?.find(computer => computer.id === computerId);
|
|
137
|
+
return known ? { name: known.name, managed: known.managed } : { managed };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function useRenameComputer(computerId: string) {
|
|
141
|
+
const client = useQueryClient();
|
|
142
|
+
return useMutation({
|
|
143
|
+
mutationFn: async (name: string) => managedComputer((await computerClient.renameComputer({ computerId, name: name.trim() })).computer!),
|
|
144
|
+
onSuccess: () => { void client.invalidateQueries({ queryKey: computersKey }); },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function useSetRoomRules(computerId: string) {
|
|
149
|
+
const client = useQueryClient();
|
|
150
|
+
return useMutation({
|
|
151
|
+
mutationFn: async ({ capacity, policy }: { capacity: number; policy: AdmissionPolicy }) => managedComputer((await computerClient.setRoomRules({
|
|
152
|
+
computerId, capacity: BigInt(capacity), admissionPolicy: policy === "queue" ? AdmissionPolicyMessage.QUEUE : AdmissionPolicyMessage.REJECT,
|
|
153
|
+
})).computer!),
|
|
154
|
+
onSuccess: () => { void client.invalidateQueries({ queryKey: computersKey }); },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Removes one of the person's Managed Computers for good. The lists of
|
|
159
|
+
* Computers, and of those a Work may start on, no longer show it, nor one
|
|
160
|
+
* that no longer exists. */
|
|
161
|
+
export function useRemoveComputer(computerId: string) {
|
|
162
|
+
const client = useQueryClient();
|
|
163
|
+
const refresh = () => {
|
|
164
|
+
void client.invalidateQueries({ queryKey: computersKey });
|
|
165
|
+
void client.invalidateQueries({ queryKey: ["usable-computers"] });
|
|
166
|
+
};
|
|
167
|
+
return useMutation({
|
|
168
|
+
mutationFn: async () => { await computerClient.removeComputer({ computerId }); },
|
|
169
|
+
onSuccess: refresh,
|
|
170
|
+
onError: (error) => { if (ConnectError.from(error).code === Code.NotFound) refresh(); },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Why a Computer wasn't removed, as the person acts on it. */
|
|
175
|
+
export function removeComputerErrorMessage(error: unknown): string {
|
|
176
|
+
if (ConnectError.from(error).code === Code.NotFound) return "This computer no longer exists.";
|
|
177
|
+
return "The computer couldn’t be removed. Try again.";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Why room rules weren't saved, as the person acts on it. */
|
|
181
|
+
export function roomRulesErrorMessage(error: unknown): string {
|
|
182
|
+
const code = ConnectError.from(error).code;
|
|
183
|
+
if (code === Code.FailedPrecondition) return "It runs more Works now than that Capacity. Choose a higher Capacity or try again once some end.";
|
|
184
|
+
if (code === Code.NotFound) return "This computer no longer exists.";
|
|
185
|
+
return "The settings couldn’t be saved. Try again.";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A user code as Talgent Computer shows it ("ABCD-EFGH"), from what the
|
|
189
|
+
* person typed or the link carried. */
|
|
190
|
+
export function normalizeUserCode(value: string | null | undefined): string {
|
|
191
|
+
const compact = (value ?? "").replace(/[^0-9a-z]/gi, "").toUpperCase();
|
|
192
|
+
return compact.length > 4 ? `${compact.slice(0, 4)}-${compact.slice(4)}` : compact;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export type RegistrationRefusal = "invalid" | "taken" | "failed";
|
|
196
|
+
|
|
197
|
+
/** Why reading or approving a registration failed, as the person acts on it. */
|
|
198
|
+
export function registrationRefusal(error: unknown): RegistrationRefusal {
|
|
199
|
+
const code = ConnectError.from(error).code;
|
|
200
|
+
if (code === Code.NotFound) return "invalid";
|
|
201
|
+
if (code === Code.FailedPrecondition) return "taken";
|
|
202
|
+
return "failed";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const registrationRefusalText: Record<RegistrationRefusal, string> = {
|
|
206
|
+
invalid: "This code is invalid or expired",
|
|
207
|
+
taken: "Another person already approved this code",
|
|
208
|
+
failed: "This code couldn’t be checked",
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export type Registration = { machineName: string; expiresAt: Date | null; approved: boolean };
|
|
212
|
+
|
|
213
|
+
export function useRegistration(userCode: string) {
|
|
214
|
+
return useQuery({
|
|
215
|
+
queryKey: ["computer-registration", userCode],
|
|
216
|
+
queryFn: async ({ signal }): Promise<Registration> => {
|
|
217
|
+
const response = await computerClient.getRegistration({ userCode }, { signal });
|
|
218
|
+
const expires = Number(response.expiresAtUnixMs);
|
|
219
|
+
return { machineName: response.machineName, expiresAt: expires > 0 ? new Date(expires) : null, approved: response.approved };
|
|
220
|
+
},
|
|
221
|
+
enabled: userCode.length > 0,
|
|
222
|
+
retry: false,
|
|
223
|
+
refetchOnWindowFocus: false,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function useApproveRegistration() {
|
|
228
|
+
const client = useQueryClient();
|
|
229
|
+
return useMutation({
|
|
230
|
+
mutationFn: async (userCode: string) => { await computerClient.approveRegistration({ userCode }); },
|
|
231
|
+
onSuccess: () => { void client.invalidateQueries({ queryKey: computersKey }); },
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** How long a pending registration has left ("Expires in 9m"). */
|
|
236
|
+
export function expiryLabel(expiresAt: Date | null, now = Date.now()): string {
|
|
237
|
+
if (!expiresAt) return "";
|
|
238
|
+
const minutes = Math.ceil((expiresAt.getTime() - now) / 60_000);
|
|
239
|
+
if (minutes <= 0) return "Expired";
|
|
240
|
+
return minutes < 60 ? `Expires in ${minutes}m` : `Expires in ${Math.floor(minutes / 60)}h`;
|
|
241
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DashboardWindow } from "./dashboard";
|
|
2
|
+
|
|
3
|
+
export interface DashboardReportPeriod {
|
|
4
|
+
start: Date;
|
|
5
|
+
end: Date;
|
|
6
|
+
stepSeconds: number;
|
|
7
|
+
bucketCount: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Fixed elapsed UTC intervals shared by both perspectives and their queries.
|
|
11
|
+
export function dashboardReportPeriod(range: DashboardWindow, now: Date): DashboardReportPeriod {
|
|
12
|
+
const stepSeconds = range === "24h" ? 7200 : range === "7d" ? 43200 : 172800;
|
|
13
|
+
const bucketCount = range === "24h" ? 12 : range === "7d" ? 14 : 15;
|
|
14
|
+
const alignment = range === "24h" ? 7200000 : 86400000;
|
|
15
|
+
const end = new Date((Math.floor(now.getTime() / alignment) + 1) * alignment);
|
|
16
|
+
return { start: new Date(end.getTime() - stepSeconds * bucketCount * 1000), end, stepSeconds, bucketCount };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function previousReportPeriod(period: DashboardReportPeriod): DashboardReportPeriod {
|
|
20
|
+
return { ...period, start: new Date(period.start.getTime() - (period.end.getTime() - period.start.getTime())), end: period.start };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function reportBucketLabels(period: DashboardReportPeriod, index: number) {
|
|
24
|
+
const start = new Date(period.start.getTime() + index * period.stepSeconds * 1000);
|
|
25
|
+
const end = new Date(start.getTime() + period.stepSeconds * 1000);
|
|
26
|
+
const date = start.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
|
27
|
+
const time = start.toISOString().slice(11, 16);
|
|
28
|
+
return {
|
|
29
|
+
label: period.stepSeconds === 7200 ? time : period.stepSeconds === 43200 ? `${date} · ${time}` : date,
|
|
30
|
+
interval: `${start.toISOString().slice(0,16).replace("T", " ")} – ${end.toISOString().slice(0,16).replace("T", " ")} UTC`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function compareMetric(current: number | null, previous: number | null) {
|
|
35
|
+
if (current === null || previous === null) return { direction: "unknown", percent: null } as const;
|
|
36
|
+
if (previous === 0 && current !== 0) return { direction: "new", percent: null } as const;
|
|
37
|
+
return { direction: current > previous ? "up" : current < previous ? "down" : "flat", percent: previous === 0 ? 0 : (current - previous) / previous * 100 } as const;
|
|
38
|
+
}
|
package/lib/dashboard.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type DashboardWindow = "24h" | "7d" | "30d";
|
|
2
|
+
export const dashboardWindows: DashboardWindow[] = ["24h", "7d", "30d"];
|
|
3
|
+
|
|
4
|
+
export function parseDashboardWindow(value: string | null | undefined): DashboardWindow {
|
|
5
|
+
return value === "7d" || value === "30d" ? value : "24h";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatDashboardNumber(value: number): string {
|
|
9
|
+
if (value < 1000) return `${Math.round(value)}`;
|
|
10
|
+
if (value < 1_000_000) return `${formatCompact(value / 1000, 1)}k`;
|
|
11
|
+
if (value < 1_000_000_000) return `${formatCompact(value / 1_000_000, 1)}M`;
|
|
12
|
+
return `${formatCompact(value / 1_000_000_000, 1)}B`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatDashboardTokens(value: number): string {
|
|
16
|
+
return `${formatDashboardNumber(value)} tok`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatCompact(value: number, fractionDigits: number): string {
|
|
20
|
+
return value.toFixed(fractionDigits).replace(/\.0$/, "");
|
|
21
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { JsonObject } from "@bufbuild/protobuf";
|
|
2
|
+
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import type { Connector, InstalledPlugin, PluginSummary } from "@talgent/workbench-contracts/marketplace/public/v1/marketplace_pb";
|
|
4
|
+
import { marketplaceClient } from "./client";
|
|
5
|
+
import { useCurrentUser } from "./account-service";
|
|
6
|
+
|
|
7
|
+
// Marketplace reads and personal changes. Catalog data is platform-wide; every
|
|
8
|
+
// personal query is keyed by the signed-in user so another session never
|
|
9
|
+
// reuses it. A personal change also refreshes Start Work defaults.
|
|
10
|
+
export const marketplaceQueryKey = ["marketplace"] as const;
|
|
11
|
+
const pageSize = 100;
|
|
12
|
+
|
|
13
|
+
function usePersonalKey(...parts: string[]) {
|
|
14
|
+
const userId = useCurrentUser().data?.user?.id ?? "";
|
|
15
|
+
return { key: [...marketplaceQueryKey, ...parts, userId] as const, enabled: Boolean(userId) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The catalog has a few hundred Plugins; lists group and search all of them.
|
|
19
|
+
// A list carries summaries; a Plugin's full release is read by its page.
|
|
20
|
+
async function listAllPlugins(signal: AbortSignal) {
|
|
21
|
+
const plugins: PluginSummary[] = [];
|
|
22
|
+
for (let cursor = ""; ;) {
|
|
23
|
+
const page = await marketplaceClient.listPlugins({ cursor, pageSize }, { signal });
|
|
24
|
+
plugins.push(...page.plugins);
|
|
25
|
+
if (!page.nextCursor) return plugins;
|
|
26
|
+
cursor = page.nextCursor;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// `enabled: false` defers a read until a surface needs it, such as the account
|
|
31
|
+
// menu, which loads what the person installed only once they reach for it.
|
|
32
|
+
type ReadOptions = { enabled?: boolean };
|
|
33
|
+
|
|
34
|
+
export function usePluginCatalog() {
|
|
35
|
+
return useQuery({ queryKey: [...marketplaceQueryKey, "plugins"], queryFn: ({ signal }) => listAllPlugins(signal), retry: false, staleTime: 60_000 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pluginQuery(slug: string) {
|
|
39
|
+
return queryOptions({ queryKey: [...marketplaceQueryKey, "plugin", slug], queryFn: async ({ signal }) => {
|
|
40
|
+
const response = await marketplaceClient.getPlugin({ slug }, { signal });
|
|
41
|
+
if (!response.plugin) throw new Error("The Plugin response is missing");
|
|
42
|
+
return response.plugin;
|
|
43
|
+
}, retry: false });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function usePlugin(slug: string) {
|
|
47
|
+
return useQuery(pluginQuery(slug));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The releases of a few Plugins by slug, sharing each Plugin page's cache. */
|
|
51
|
+
export function usePluginReleases(slugs: string[]) {
|
|
52
|
+
const results = useQueries({ queries: slugs.map(slug => pluginQuery(slug)) });
|
|
53
|
+
return new Map(results.flatMap((result, index) => result.data ? [[slugs[index], result.data] as const] : []));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useInstalledPlugins(options: ReadOptions = {}) {
|
|
57
|
+
const { key, enabled } = usePersonalKey("installed-plugins");
|
|
58
|
+
return useQuery({ queryKey: key, enabled: enabled && options.enabled !== false, retry: false, queryFn: async ({ signal }) => {
|
|
59
|
+
const installed = new Map<string, InstalledPlugin>();
|
|
60
|
+
for (let cursor = ""; ;) {
|
|
61
|
+
const page = await marketplaceClient.listInstalledPlugins({ cursor, pageSize }, { signal });
|
|
62
|
+
for (const plugin of page.plugins) installed.set(plugin.slug, plugin);
|
|
63
|
+
if (!page.nextCursor) return installed;
|
|
64
|
+
cursor = page.nextCursor;
|
|
65
|
+
}
|
|
66
|
+
} });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useConnectors() {
|
|
70
|
+
const { key, enabled } = usePersonalKey("connectors");
|
|
71
|
+
return useQuery({ queryKey: key, enabled, retry: false, queryFn: async ({ signal }) => (await marketplaceClient.listConnectors({}, { signal })).connectors });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useInstalledConnectors(options: ReadOptions = {}) {
|
|
75
|
+
const { key, enabled } = usePersonalKey("installed-connectors");
|
|
76
|
+
return useQuery({ queryKey: key, enabled: enabled && options.enabled !== false, retry: false, queryFn: async ({ signal }) => (await marketplaceClient.listInstalledConnectors({}, { signal })).connectors });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useConnector(name: string) {
|
|
80
|
+
const { key, enabled } = usePersonalKey("connector", name);
|
|
81
|
+
return useQuery({ queryKey: key, enabled, retry: false, queryFn: ({ signal }) => marketplaceClient.getConnector({ name }, { signal }) });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function useRefreshPersonal() {
|
|
85
|
+
const cache = useQueryClient();
|
|
86
|
+
return () => Promise.all([
|
|
87
|
+
cache.invalidateQueries({ queryKey: marketplaceQueryKey }),
|
|
88
|
+
cache.invalidateQueries({ queryKey: ["work-start-options"] }),
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function usePluginActions() {
|
|
93
|
+
const refresh = useRefreshPersonal();
|
|
94
|
+
const install = useMutation({ mutationFn: async ({ slug, configuration }: { slug: string; configuration: JsonObject }) => {
|
|
95
|
+
const response = await marketplaceClient.installPlugin({ slug, configuration });
|
|
96
|
+
if (response.plugin?.slug !== slug) throw new Error("The installation was not confirmed");
|
|
97
|
+
return response.plugin;
|
|
98
|
+
}, onSettled: refresh });
|
|
99
|
+
const setEnabled = useMutation({ mutationFn: async ({ installed, enabled }: { installed: InstalledPlugin; enabled: boolean }) => {
|
|
100
|
+
const request = { slug: installed.slug, version: installed.version };
|
|
101
|
+
await (enabled ? marketplaceClient.enablePlugin(request) : marketplaceClient.disablePlugin(request));
|
|
102
|
+
}, onSettled: refresh });
|
|
103
|
+
// Setting merges; settings the user cleared are deleted afterwards.
|
|
104
|
+
const configure = useMutation({ mutationFn: async ({ installed, values, cleared = [] }: { installed: InstalledPlugin; values: JsonObject; cleared?: string[] }) => {
|
|
105
|
+
let version = installed.version;
|
|
106
|
+
if (Object.keys(values).length || !cleared.length) version = (await marketplaceClient.setPluginConfiguration({ slug: installed.slug, version, values })).plugin?.version ?? version;
|
|
107
|
+
if (cleared.length) await marketplaceClient.deletePluginConfiguration({ slug: installed.slug, version, names: cleared });
|
|
108
|
+
}, onSettled: refresh });
|
|
109
|
+
const uninstall = useMutation({ mutationFn: (installed: InstalledPlugin) => marketplaceClient.uninstallPlugin({ slug: installed.slug, version: installed.version }), onSettled: refresh });
|
|
110
|
+
return { install, setEnabled, configure, uninstall };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function useConnectorActions() {
|
|
114
|
+
const refresh = useRefreshPersonal();
|
|
115
|
+
const install = useMutation({ mutationFn: async (name: string) => {
|
|
116
|
+
const response = await marketplaceClient.installConnector({ name });
|
|
117
|
+
if (response.connector?.name !== name) throw new Error("The installation was not confirmed");
|
|
118
|
+
return response.connector;
|
|
119
|
+
}, onSettled: refresh });
|
|
120
|
+
const setEnabled = useMutation({ mutationFn: async ({ connector, enabled }: { connector: Connector; enabled: boolean }) => {
|
|
121
|
+
const request = { name: connector.name, version: connector.installationVersion };
|
|
122
|
+
await (enabled ? marketplaceClient.enableConnector(request) : marketplaceClient.disableConnector(request));
|
|
123
|
+
}, onSettled: refresh });
|
|
124
|
+
const uninstall = useMutation({ mutationFn: (connector: Connector) => marketplaceClient.uninstallConnector({ name: connector.name, version: connector.installationVersion }), onSettled: refresh });
|
|
125
|
+
return { install, setEnabled, uninstall };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Status vocabulary shared by Plugins and Connectors.
|
|
129
|
+
export type InstallStatus = "always_enabled" | "enabled" | "disabled" | "not_installed";
|
|
130
|
+
export const installStatusLabel: Record<InstallStatus, string> = { always_enabled: "Always enabled", enabled: "Enabled", disabled: "Disabled", not_installed: "Not installed" };
|
|
131
|
+
|
|
132
|
+
export function pluginStatus(required: boolean, installed?: InstalledPlugin): InstallStatus {
|
|
133
|
+
if (required) return "always_enabled";
|
|
134
|
+
if (!installed) return "not_installed";
|
|
135
|
+
return installed.enabled ? "enabled" : "disabled";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const connectorStatus = (connector: Connector) => connector.status as InstallStatus;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { marketplaceClient } from "./client";
|
|
3
|
+
import { useCurrentUser } from "./account-service";
|
|
4
|
+
import { marketplaceQueryKey } from "./marketplace-catalog";
|
|
5
|
+
import { notify } from "./notifications";
|
|
6
|
+
|
|
7
|
+
// Provider authorization is a round trip through the Provider. The GitHub App
|
|
8
|
+
// registers /profile as its callback URL, so the redirect always returns there;
|
|
9
|
+
// the page that started the connection is remembered and restored afterwards.
|
|
10
|
+
// Removal condition: once the App's callback URL is the Provider page, pass
|
|
11
|
+
// that page as the redirect and drop the remembered return path.
|
|
12
|
+
export const providerCallbackPath = "/profile";
|
|
13
|
+
const returnKey = "talgent.providerAuthorization.return";
|
|
14
|
+
|
|
15
|
+
export const providerNames: Record<string, string> = { github: "GitHub" };
|
|
16
|
+
export const providerName = (provider: string) => providerNames[provider] ?? provider;
|
|
17
|
+
// Providers supported by the platform. Only GitHub exists today.
|
|
18
|
+
export const supportedProviders = ["github"] as const;
|
|
19
|
+
|
|
20
|
+
const callbackURI = () => `${window.location.origin}${providerCallbackPath}`;
|
|
21
|
+
|
|
22
|
+
/** The connection's stored status only, without asking the Provider what it reaches. */
|
|
23
|
+
export function useProviderConnection(provider: string) {
|
|
24
|
+
const userId = useCurrentUser().data?.user?.id ?? "";
|
|
25
|
+
return useQuery({ queryKey: [...marketplaceQueryKey, "provider-state", provider, userId], enabled: Boolean(userId), retry: false,
|
|
26
|
+
queryFn: ({ signal }) => marketplaceClient.getProviderConnectionState({ provider }, { signal }) });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const providerAccessKey = (provider: string, userId: string) => [...marketplaceQueryKey, "provider", provider, userId];
|
|
30
|
+
|
|
31
|
+
/** The connection and its reach (accounts, permissions) as last seen; reading never asks the Provider. */
|
|
32
|
+
export function useProviderAccess(provider: string) {
|
|
33
|
+
const userId = useCurrentUser().data?.user?.id ?? "";
|
|
34
|
+
return useQuery({ queryKey: providerAccessKey(provider, userId), enabled: Boolean(userId), retry: false,
|
|
35
|
+
queryFn: ({ signal }) => marketplaceClient.getProviderAccess({ provider }, { signal }) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Asks the Provider now what the authorization reaches; the page then shows the result. */
|
|
39
|
+
export function useRefreshProviderAccess(provider: string) {
|
|
40
|
+
const userId = useCurrentUser().data?.user?.id ?? "";
|
|
41
|
+
const cache = useQueryClient();
|
|
42
|
+
return useMutation({
|
|
43
|
+
mutationFn: async () => {
|
|
44
|
+
const response = await marketplaceClient.refreshProviderAccess({ provider });
|
|
45
|
+
if (!response.access) throw new Error("The refreshed access is missing");
|
|
46
|
+
return response.access;
|
|
47
|
+
},
|
|
48
|
+
onSuccess: access => {
|
|
49
|
+
cache.setQueryData(providerAccessKey(provider, userId), access);
|
|
50
|
+
return cache.invalidateQueries({ queryKey: ["work-start-repositories"] });
|
|
51
|
+
},
|
|
52
|
+
onError: () => notify(`provider:${provider}`, "danger", `${providerName(provider)} couldn’t be checked`, "Try again in a moment."),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Starts connecting; the browser leaves for the Provider on success.
|
|
57
|
+
export function useConnectProvider() {
|
|
58
|
+
return useMutation({
|
|
59
|
+
mutationFn: async ({ provider, returnTo }: { provider: string; returnTo: string }) => {
|
|
60
|
+
const response = await marketplaceClient.beginProviderAuthorization({ provider, redirectUri: callbackURI() });
|
|
61
|
+
if (!response.authorizationUrl) throw new Error("The authorization address is missing");
|
|
62
|
+
window.sessionStorage.setItem(returnKey, returnTo);
|
|
63
|
+
return response.authorizationUrl;
|
|
64
|
+
},
|
|
65
|
+
onSuccess: url => window.location.assign(url),
|
|
66
|
+
onError: (_, { provider }) => notify(`provider:${provider}`, "danger", `${providerName(provider)} couldn’t be opened`, "Try connecting again."),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The page that started connecting, read once.
|
|
71
|
+
export function takeProviderReturn(provider: string) {
|
|
72
|
+
const returnTo = window.sessionStorage.getItem(returnKey) || `/marketplace/providers/${provider}`;
|
|
73
|
+
window.sessionStorage.removeItem(returnKey);
|
|
74
|
+
return returnTo;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Completes a returned authorization and reports where the user started.
|
|
78
|
+
export async function completeProviderAuthorization(provider: string, code: string, state: string) {
|
|
79
|
+
const response = await marketplaceClient.completeProviderAuthorization({ provider, code, state, redirectUri: callbackURI() });
|
|
80
|
+
if (!response.authorization) throw new Error("The authorization is missing");
|
|
81
|
+
return { authorization: response.authorization, returnTo: takeProviderReturn(provider) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Disconnecting asks the Provider to revoke; success means it was confirmed.
|
|
85
|
+
export function useDisconnectProvider() {
|
|
86
|
+
const cache = useQueryClient();
|
|
87
|
+
return useMutation({
|
|
88
|
+
mutationFn: async (authorizationId: string) => {
|
|
89
|
+
const response = await marketplaceClient.cancelPersonalAuthorization({ authorizationId });
|
|
90
|
+
if (response.authorization?.id !== authorizationId || response.authorization.status !== "revoked") throw new Error("Revocation was not confirmed");
|
|
91
|
+
},
|
|
92
|
+
onSettled: () => Promise.all([cache.invalidateQueries({ queryKey: marketplaceQueryKey }), cache.invalidateQueries({ queryKey: ["work-start-options"] }), cache.invalidateQueries({ queryKey: ["work-start-repositories"] })]),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Display names for services whose catalog name is a lowercase identifier.
|
|
97
|
+
const serviceNames: Record<string, string> = { github: "GitHub", talgent: "Talgent" };
|
|
98
|
+
export const serviceLabel = (name: string) => serviceNames[name] ?? name.split(/[-_]/).map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { PluginRelease } from "@talgent/workbench-contracts/marketplace/public/v1/marketplace_pb";
|
|
2
|
+
|
|
3
|
+
/** What presenting a Plugin needs; a full release and a catalog summary both carry it. */
|
|
4
|
+
export type PresentablePlugin = Pick<PluginRelease, "slug" | "source" | "nativeEntry">;
|
|
5
|
+
|
|
6
|
+
export function pluginDisplayName(slug: string): string {
|
|
7
|
+
return slug.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function nativeObject(bytes: Uint8Array): Record<string, unknown> | undefined {
|
|
11
|
+
try {
|
|
12
|
+
const value: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
14
|
+
} catch { return undefined; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const text = (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : "";
|
|
18
|
+
|
|
19
|
+
// Presentation comes from the release's native marketplace entry.
|
|
20
|
+
export function pluginPresentation(plugin: PresentablePlugin) {
|
|
21
|
+
const entry = nativeObject(plugin.nativeEntry);
|
|
22
|
+
const author = entry?.author;
|
|
23
|
+
const keywords = Array.isArray(entry?.keywords) ? entry.keywords : Array.isArray(entry?.tags) ? entry.tags : [];
|
|
24
|
+
const homepage = text(entry?.homepage) || text(entry?.repository);
|
|
25
|
+
return {
|
|
26
|
+
name: text(entry?.displayName) || pluginDisplayName(plugin.slug),
|
|
27
|
+
category: text(entry?.category) ? pluginDisplayName(text(entry?.category)) : "Other",
|
|
28
|
+
author: text(author) || (author && typeof author === "object" ? text((author as Record<string, unknown>).name) : ""),
|
|
29
|
+
license: text(entry?.license),
|
|
30
|
+
homepage: /^https:\/\//.test(homepage) ? homepage : "",
|
|
31
|
+
keywords: keywords.filter((word): word is string => typeof word === "string" && Boolean(word.trim())),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The core runtime Plugin is required by every person's Agent definition; it
|
|
36
|
+
// is always enabled and cannot be installed or removed personally.
|
|
37
|
+
export const platformPluginSlugs: readonly string[] = ["talgent-runtime"];
|
|
38
|
+
export const isPlatformPlugin = (slug: string) => platformPluginSlugs.includes(slug);
|
|
39
|
+
|
|
40
|
+
export function groupPluginsByCategory<T extends PresentablePlugin>(plugins: T[]) {
|
|
41
|
+
const groups = new Map<string, T[]>();
|
|
42
|
+
for (const plugin of plugins) {
|
|
43
|
+
const { category } = pluginPresentation(plugin);
|
|
44
|
+
const entries = groups.get(category) ?? [];
|
|
45
|
+
entries.push(plugin);
|
|
46
|
+
groups.set(category, entries);
|
|
47
|
+
}
|
|
48
|
+
return [...groups].sort(([left], [right]) => {
|
|
49
|
+
if (left === "Other") return 1;
|
|
50
|
+
if (right === "Other") return -1;
|
|
51
|
+
return left.localeCompare(right);
|
|
52
|
+
}).map(([category, entries]) => ({ category, plugins: entries }));
|
|
53
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Marketplace is pages only; depth lives in these paths and the topbar trail.
|
|
2
|
+
const encode = encodeURIComponent;
|
|
3
|
+
export const marketplacePaths = {
|
|
4
|
+
plugins: "/marketplace",
|
|
5
|
+
pluginCategory: (category: string) => `/marketplace/plugins/categories/${encode(category)}`,
|
|
6
|
+
plugin: (slug: string) => `/marketplace/plugins/${encode(slug)}`,
|
|
7
|
+
connectors: "/marketplace/connectors",
|
|
8
|
+
connectorCategory: (category: string) => `/marketplace/connectors/categories/${encode(category)}`,
|
|
9
|
+
connector: (name: string) => `/marketplace/connectors/${encode(name)}`,
|
|
10
|
+
providers: "/marketplace/providers",
|
|
11
|
+
provider: (id: string) => `/marketplace/providers/${encode(id)}`,
|
|
12
|
+
};
|