@anchrd/intel-ui 0.2.1 → 0.4.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.
Files changed (33) hide show
  1. package/components.json +7 -1
  2. package/package.json +3 -1
  3. package/src/app/app-sidebar/app-sidebar.tsx +56 -0
  4. package/src/app/app-tree/app-tree.tsx +427 -0
  5. package/src/app/app.tsx +84 -54
  6. package/src/app/header-actions/header-actions.tsx +15 -0
  7. package/src/app/header-search/header-search.tsx +291 -0
  8. package/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  9. package/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  10. package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +85 -0
  11. package/src/app/user-footer/user-footer.tsx +76 -0
  12. package/src/components/ui/breadcrumb.tsx +102 -0
  13. package/src/components/ui/button.tsx +64 -0
  14. package/src/components/ui/collapsible.tsx +20 -0
  15. package/src/components/ui/command.tsx +160 -0
  16. package/src/components/ui/dialog.tsx +143 -0
  17. package/src/components/ui/dropdown-menu.tsx +84 -0
  18. package/src/components/ui/input.tsx +21 -0
  19. package/src/components/ui/separator.tsx +26 -0
  20. package/src/components/ui/sheet.tsx +136 -0
  21. package/src/components/ui/sidebar.tsx +693 -0
  22. package/src/components/ui/skeleton.tsx +13 -0
  23. package/src/components/ui/tooltip.tsx +51 -0
  24. package/src/data/intel-data-provider/intel-data-provider.ts +58 -39
  25. package/src/data/intel-data-provider/intel-data-provider.types.ts +17 -8
  26. package/src/flows/flows.tsx +59 -142
  27. package/src/hooks/use-mobile.ts +19 -0
  28. package/src/i18n/en.json +48 -29
  29. package/src/knowledge/knowledge.tsx +133 -423
  30. package/src/router/router.tsx +4 -0
  31. package/src/router/selection-search.ts +19 -0
  32. package/src/styles.css +54 -51
  33. package/src/tools/tools.tsx +175 -158
@@ -7,6 +7,7 @@ import {
7
7
  } from "@tanstack/react-router";
8
8
  import { App } from "@/app/app.tsx";
9
9
  import type { RouterContext } from "./router.types.ts";
10
+ import { selectionSearch } from "./selection-search.ts";
10
11
 
11
12
  const rootRoute = createRootRouteWithContext<RouterContext>()({ component: App });
12
13
  const redirectRoute = createRoute({
@@ -17,16 +18,19 @@ const redirectRoute = createRoute({
17
18
  const knowledgeRoute = createRoute({
18
19
  getParentRoute: () => rootRoute,
19
20
  path: "/knowledge",
21
+ validateSearch: selectionSearch,
20
22
  component: lazyRouteComponent(() => import("@/knowledge/knowledge.tsx"), "Knowledge"),
21
23
  });
22
24
  const flowsRoute = createRoute({
23
25
  getParentRoute: () => rootRoute,
24
26
  path: "/flows",
27
+ validateSearch: selectionSearch,
25
28
  component: lazyRouteComponent(() => import("@/flows/flows.tsx"), "Flows"),
26
29
  });
27
30
  const toolsRoute = createRoute({
28
31
  getParentRoute: () => rootRoute,
29
32
  path: "/tools",
33
+ validateSearch: selectionSearch,
30
34
  component: lazyRouteComponent(() => import("@/tools/tools.tsx"), "Tools"),
31
35
  });
32
36
  const routeTree = rootRoute.addChildren([redirectRoute, knowledgeRoute, flowsRoute, toolsRoute]);
@@ -0,0 +1,19 @@
1
+ export type SelectionSearch = Record<string, unknown> & { select?: string };
2
+
3
+ // One search parameter carries "open this one" into an area: the header search names a hit, the
4
+ // screen for that area selects it. Keeping it in the URL is what makes a hit reachable with Enter
5
+ // alone — the dialog never reaches into a screen's own state.
6
+ //
7
+ // ⚠️ Unknown parameters are passed through rather than dropped. The portal's connect flow returns
8
+ // to `/tools` with its own marker, and a validator that kept only what it knows would delete it.
9
+ export function selectionSearch(search: Record<string, unknown>): SelectionSearch {
10
+ const { select, ...rest } = search;
11
+ return typeof select === "string" && select.length > 0 ? { ...rest, select } : rest;
12
+ }
13
+
14
+ // Screens are lazy route components with no route object at hand, so they read the parameter off
15
+ // the location instead of off a typed route.
16
+ export function selectedFrom(search: unknown): string | null {
17
+ const select = (search as { select?: unknown } | null)?.select;
18
+ return typeof select === "string" && select.length > 0 ? select : null;
19
+ }
package/src/styles.css CHANGED
@@ -7,65 +7,68 @@
7
7
 
8
8
  @custom-variant dark (&:is(.dark *));
9
9
 
10
+ /* The default theme is gate's, token for token: an achromatic base whose primary is black rather
11
+ than Intel's former blue (hue 265). Every value here is a default — `theme/custom.css` is loaded
12
+ after this file and a customer theme overrides any of them without touching a component. */
10
13
  :root {
11
14
  --radius: 0.625rem;
12
- --background: oklch(0.99 0.005 265);
13
- --foreground: oklch(0.18 0.02 265);
15
+ --background: oklch(1 0 0);
16
+ --foreground: oklch(0.145 0 0);
14
17
  --card: oklch(1 0 0);
15
- --card-foreground: oklch(0.18 0.02 265);
18
+ --card-foreground: oklch(0.145 0 0);
16
19
  --popover: oklch(1 0 0);
17
- --popover-foreground: oklch(0.18 0.02 265);
18
- --primary: oklch(0.45 0.17 265);
19
- --primary-foreground: oklch(0.98 0.01 265);
20
- --secondary: oklch(0.95 0.02 265);
21
- --secondary-foreground: oklch(0.25 0.04 265);
22
- --muted: oklch(0.96 0.01 265);
23
- --muted-foreground: oklch(0.5 0.03 265);
24
- --accent: oklch(0.94 0.03 265);
25
- --accent-foreground: oklch(0.25 0.05 265);
26
- --destructive: oklch(0.58 0.23 27);
27
- --destructive-foreground: oklch(0.98 0.01 27);
28
- --border: oklch(0.9 0.02 265);
29
- --input: oklch(0.9 0.02 265);
30
- --ring: oklch(0.58 0.15 265);
31
- --sidebar: oklch(0.97 0.015 265);
32
- --sidebar-foreground: oklch(0.2 0.03 265);
33
- --sidebar-primary: oklch(0.45 0.17 265);
34
- --sidebar-primary-foreground: oklch(0.98 0.01 265);
35
- --sidebar-accent: oklch(0.92 0.035 265);
36
- --sidebar-accent-foreground: oklch(0.24 0.06 265);
37
- --sidebar-border: oklch(0.88 0.025 265);
38
- --sidebar-ring: oklch(0.58 0.15 265);
20
+ --popover-foreground: oklch(0.145 0 0);
21
+ --primary: oklch(0.205 0 0);
22
+ --primary-foreground: oklch(0.985 0 0);
23
+ --secondary: oklch(0.97 0 0);
24
+ --secondary-foreground: oklch(0.205 0 0);
25
+ --muted: oklch(0.97 0 0);
26
+ --muted-foreground: oklch(0.556 0 0);
27
+ --accent: oklch(0.97 0 0);
28
+ --accent-foreground: oklch(0.205 0 0);
29
+ --destructive: oklch(0.577 0.245 27.325);
30
+ --destructive-foreground: oklch(0.985 0 0);
31
+ --border: oklch(0.922 0 0);
32
+ --input: oklch(0.922 0 0);
33
+ --ring: oklch(0.708 0 0);
34
+ --sidebar: oklch(0.985 0 0);
35
+ --sidebar-foreground: oklch(0.145 0 0);
36
+ --sidebar-primary: oklch(0.205 0 0);
37
+ --sidebar-primary-foreground: oklch(0.985 0 0);
38
+ --sidebar-accent: oklch(0.97 0 0);
39
+ --sidebar-accent-foreground: oklch(0.205 0 0);
40
+ --sidebar-border: oklch(0.922 0 0);
41
+ --sidebar-ring: oklch(0.708 0 0);
39
42
  }
40
43
 
41
44
  .dark {
42
- --background: oklch(0.15 0.025 265);
43
- --foreground: oklch(0.96 0.01 265);
44
- --card: oklch(0.19 0.03 265);
45
- --card-foreground: oklch(0.96 0.01 265);
46
- --popover: oklch(0.19 0.03 265);
47
- --popover-foreground: oklch(0.96 0.01 265);
48
- --primary: oklch(0.7 0.16 265);
49
- --primary-foreground: oklch(0.16 0.03 265);
50
- --secondary: oklch(0.25 0.035 265);
51
- --secondary-foreground: oklch(0.95 0.01 265);
52
- --muted: oklch(0.24 0.025 265);
53
- --muted-foreground: oklch(0.7 0.025 265);
54
- --accent: oklch(0.27 0.05 265);
55
- --accent-foreground: oklch(0.96 0.01 265);
56
- --destructive: oklch(0.7 0.19 25);
57
- --destructive-foreground: oklch(0.98 0.01 25);
58
- --border: oklch(1 0 0 / 12%);
59
- --input: oklch(1 0 0 / 16%);
60
- --ring: oklch(0.66 0.14 265);
61
- --sidebar: oklch(0.18 0.035 265);
62
- --sidebar-foreground: oklch(0.95 0.01 265);
63
- --sidebar-primary: oklch(0.7 0.16 265);
64
- --sidebar-primary-foreground: oklch(0.16 0.03 265);
65
- --sidebar-accent: oklch(0.26 0.05 265);
66
- --sidebar-accent-foreground: oklch(0.96 0.01 265);
45
+ --background: oklch(0.145 0 0);
46
+ --foreground: oklch(0.985 0 0);
47
+ --card: oklch(0.205 0 0);
48
+ --card-foreground: oklch(0.985 0 0);
49
+ --popover: oklch(0.205 0 0);
50
+ --popover-foreground: oklch(0.985 0 0);
51
+ --primary: oklch(0.922 0 0);
52
+ --primary-foreground: oklch(0.205 0 0);
53
+ --secondary: oklch(0.269 0 0);
54
+ --secondary-foreground: oklch(0.985 0 0);
55
+ --muted: oklch(0.269 0 0);
56
+ --muted-foreground: oklch(0.708 0 0);
57
+ --accent: oklch(0.269 0 0);
58
+ --accent-foreground: oklch(0.985 0 0);
59
+ --destructive: oklch(0.704 0.191 22.216);
60
+ --destructive-foreground: oklch(0.985 0 0);
61
+ --border: oklch(1 0 0 / 10%);
62
+ --input: oklch(1 0 0 / 15%);
63
+ --ring: oklch(0.556 0 0);
64
+ --sidebar: oklch(0.205 0 0);
65
+ --sidebar-foreground: oklch(0.985 0 0);
66
+ --sidebar-primary: oklch(0.922 0 0);
67
+ --sidebar-primary-foreground: oklch(0.205 0 0);
68
+ --sidebar-accent: oklch(0.269 0 0);
69
+ --sidebar-accent-foreground: oklch(0.985 0 0);
67
70
  --sidebar-border: oklch(1 0 0 / 10%);
68
- --sidebar-ring: oklch(0.66 0.14 265);
71
+ --sidebar-ring: oklch(0.556 0 0);
69
72
  }
70
73
 
71
74
  @theme inline {
@@ -1,192 +1,209 @@
1
1
  import type { ToolCapability } from "@anchrd/intel-contract";
2
- import { useMutation, useQuery } from "@tanstack/react-query";
3
- import { AlertTriangle, LogIn, Play, ShieldAlert, Wrench } from "lucide-react";
4
- import { useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useRouterState } from "@tanstack/react-router";
4
+ import { AlertTriangle, ChevronRight, LogIn, PlugZap, Wrench } from "lucide-react";
5
+ import type * as React from "react";
6
+ import { HeaderActions } from "@/app/header-actions/header-actions.tsx";
7
+ import { Button, buttonVariants } from "@/components/ui/button";
8
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
9
+ import type { I18n } from "@/i18n/i18n.types.ts";
5
10
  import { useIntelRouterContext } from "@/router/router-context.ts";
11
+ import { selectedFrom } from "@/router/selection-search.ts";
6
12
 
13
+ // The portal namespaces every tool as `<server>__<tool>`, and that prefix is the whole of what
14
+ // Intel ever learns about a tool's origin: the portal resolves the real server and holds its
15
+ // credentials (ADR-0003). A name without a namespace has no origin beyond the portal itself.
16
+ const NamespaceSeparator = "__";
17
+
18
+ function toolOrigin(name: string): string | null {
19
+ const boundary = name.indexOf(NamespaceSeparator);
20
+ return boundary > 0 ? name.slice(0, boundary) : null;
21
+ }
22
+
23
+ // The catalog is one live `tools/list` with the signed-in user's own portal token. Nothing here is
24
+ // stored, mirrored or administered — the screen shows what the portal answers and nothing else.
7
25
  export function Tools() {
8
26
  const { data, i18n } = useIntelRouterContext();
9
27
  const catalog = useQuery({ queryKey: ["tools"], queryFn: () => data.listTools() });
10
- const [selected, setSelected] = useState<ToolCapability | null>(null);
11
- const connectError =
12
- typeof window === "undefined"
13
- ? null
14
- : new URLSearchParams(window.location.search).get("connectError");
28
+ // A hit from the header search arrives as `?select=<tool name>`, and the row it names opens with
29
+ // the list. Nothing else about the row changes: it is still only a disclosure.
30
+ const requested = useRouterState({ select: (state) => selectedFrom(state.location.search) });
31
+ // The portal connect flow returns here with a marker when it refuses. Only its presence is used:
32
+ // the value is attacker-controlled and could carry provider detail, so it is never rendered.
33
+ const connectFailed =
34
+ typeof window !== "undefined" &&
35
+ new URLSearchParams(window.location.search).has("connectError");
15
36
 
16
37
  return (
17
- <div className="min-h-screen">
18
- <header className="flex items-center justify-between gap-6 border-b px-8 py-5">
19
- <div>
20
- <h1 className="text-xl font-semibold tracking-tight">{i18n.t("tools.title")}</h1>
21
- <p className="mt-1 text-sm text-muted-foreground">{i18n.t("tools.description")}</p>
22
- </div>
23
- {catalog.data?.portalConnected && (
38
+ <div className="min-h-full p-8">
39
+ {catalog.data?.portalConnected ? (
40
+ <HeaderActions>
24
41
  <a
25
42
  href={data.portalConnectUrl("/tools")}
26
- className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
43
+ className={buttonVariants({ variant: "outline", size: "sm" })}
27
44
  >
28
- <LogIn aria-hidden="true" className="size-4" />
45
+ <LogIn aria-hidden="true" />
29
46
  {i18n.t("tools.reconnect")}
30
47
  </a>
48
+ </HeaderActions>
49
+ ) : null}
50
+
51
+ <div className="mx-auto w-full max-w-4xl space-y-5">
52
+ {connectFailed && (
53
+ <p
54
+ role="alert"
55
+ className="rounded-md border border-destructive/30 p-3 text-sm text-destructive"
56
+ >
57
+ {i18n.t("tools.connectFailed")}
58
+ </p>
31
59
  )}
32
- </header>
33
60
 
34
- <div className="grid gap-5 p-8 xl:grid-cols-[minmax(0,1fr)_24rem]">
35
- <div className="space-y-5">
36
- {connectError && (
37
- <p
38
- role="alert"
39
- className="rounded-md border border-destructive/30 p-3 text-sm text-destructive"
40
- >
41
- {i18n.t("tools.connectFailed")}
42
- </p>
43
- )}
44
- {catalog.isPending ? (
45
- <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
46
- ) : catalog.isError ? (
47
- <p role="alert" className="text-sm text-destructive">
48
- {i18n.t("tools.operationFailed")}
49
- </p>
50
- ) : !catalog.data?.portalConnected ? (
51
- <section className="rounded-xl border border-dashed bg-card p-12 text-center">
52
- <Wrench aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />
53
- <h2 className="mt-4 font-semibold">{i18n.t("tools.disconnected")}</h2>
54
- <p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">
55
- {i18n.t("tools.disconnectedHelp")}
56
- </p>
57
- <a
58
- href={data.portalConnectUrl("/tools")}
59
- className="mt-5 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring"
60
- >
61
- <LogIn aria-hidden="true" className="size-4" />
62
- {i18n.t("tools.connect")}
63
- </a>
64
- </section>
65
- ) : catalog.data.items.length === 0 ? (
66
- <section className="rounded-xl border border-dashed bg-card p-12 text-center">
67
- <Wrench aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />
68
- <h2 className="mt-4 font-semibold">{i18n.t("tools.empty")}</h2>
69
- <p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">
70
- {i18n.t("tools.emptyHelp")}
71
- </p>
72
- </section>
73
- ) : (
61
+ {catalog.isPending ? (
62
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
63
+ ) : catalog.isError ? (
64
+ // Unreachable is not empty: the portal did not answer, so Intel knows nothing about this
65
+ // user's tools rather than knowing there are none.
66
+ <Notice
67
+ alert
68
+ icon={<PlugZap aria-hidden="true" className="mx-auto size-8 text-destructive" />}
69
+ title={i18n.t("tools.unreachable")}
70
+ help={i18n.t("tools.unreachableHelp")}
71
+ >
72
+ <Button variant="outline" onClick={() => void catalog.refetch()}>
73
+ {i18n.t("common.retry")}
74
+ </Button>
75
+ </Notice>
76
+ ) : !catalog.data.portalConnected ? (
77
+ <Notice
78
+ icon={<PlugZap aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
79
+ title={i18n.t("tools.disconnected")}
80
+ help={i18n.t("tools.disconnectedHelp")}
81
+ >
82
+ <a href={data.portalConnectUrl("/tools")} className={buttonVariants()}>
83
+ <LogIn aria-hidden="true" />
84
+ {i18n.t("tools.connect")}
85
+ </a>
86
+ </Notice>
87
+ ) : catalog.data.items.length === 0 ? (
88
+ <Notice
89
+ icon={<Wrench aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
90
+ title={i18n.t("tools.empty")}
91
+ help={i18n.t("tools.emptyHelp")}
92
+ />
93
+ ) : (
94
+ <>
74
95
  <section className="overflow-hidden rounded-xl border bg-card shadow-sm">
75
96
  <ul className="divide-y">
76
97
  {catalog.data.items.map((capability) => (
77
98
  <li key={capability.name}>
78
- <button
79
- type="button"
80
- onClick={() => setSelected(capability)}
81
- className="flex w-full items-start justify-between gap-5 px-5 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
82
- >
83
- <span className="min-w-0">
84
- <span className="block text-sm font-medium">
85
- {capability.title ?? capability.name}
86
- </span>
87
- <span className="mt-1 block truncate font-mono text-xs text-muted-foreground">
88
- {capability.name}
89
- </span>
90
- {capability.description && (
91
- <span className="mt-2 line-clamp-2 block text-xs text-muted-foreground">
92
- {capability.description}
93
- </span>
94
- )}
95
- </span>
96
- {capability.annotations.destructiveHint && (
97
- <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-destructive/10 px-2 py-1 text-xs text-destructive">
98
- <AlertTriangle aria-hidden="true" className="size-3.5" />
99
- {i18n.t("tools.destructiveShort")}
100
- </span>
101
- )}
102
- </button>
99
+ <ToolEntry
100
+ capability={capability}
101
+ i18n={i18n}
102
+ open={capability.name === requested}
103
+ />
103
104
  </li>
104
105
  ))}
105
106
  </ul>
106
107
  </section>
107
- )}
108
- </div>
109
- <ToolTester capability={selected} />
108
+ <p className="text-xs text-muted-foreground">{i18n.t("tools.liveNote")}</p>
109
+ </>
110
+ )}
110
111
  </div>
111
112
  </div>
112
113
  );
113
114
  }
114
115
 
115
- function ToolTester({ capability }: { capability: ToolCapability | null }) {
116
- const { data, i18n } = useIntelRouterContext();
117
- const [argumentsText, setArgumentsText] = useState("{}");
118
- const [parseError, setParseError] = useState(false);
119
- const safeToTest =
120
- capability?.annotations.readOnlyHint === true &&
121
- capability.annotations.destructiveHint !== true;
122
- const test = useMutation({
123
- mutationFn: async () => {
124
- const parsed: unknown = JSON.parse(argumentsText);
125
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
126
- throw new SyntaxError("Arguments must be a JSON object");
127
- }
128
- return await data.testTool({
129
- name: capability?.name ?? "",
130
- arguments: parsed as Record<string, unknown>,
131
- });
132
- },
133
- onMutate: () => setParseError(false),
134
- onError: (error) => setParseError(error instanceof SyntaxError),
135
- });
116
+ function Notice({
117
+ icon,
118
+ title,
119
+ help,
120
+ alert,
121
+ children,
122
+ }: {
123
+ icon: React.ReactNode;
124
+ title: string;
125
+ help: string;
126
+ alert?: boolean;
127
+ children?: React.ReactNode;
128
+ }) {
129
+ return (
130
+ <section
131
+ {...(alert ? { role: "alert" } : {})}
132
+ className="rounded-xl border border-dashed bg-card p-12 text-center"
133
+ >
134
+ {icon}
135
+ <h2 className="mt-4 font-semibold">{title}</h2>
136
+ <p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">{help}</p>
137
+ {children ? <div className="mt-5">{children}</div> : null}
138
+ </section>
139
+ );
140
+ }
141
+
142
+ // One tool, one disclosure. The row is the only control in the list: it opens the schema and does
143
+ // nothing else — enabling, sharing or permitting a tool happens in the portal, never here.
144
+ function ToolEntry({
145
+ capability,
146
+ i18n,
147
+ open,
148
+ }: {
149
+ capability: ToolCapability;
150
+ i18n: I18n;
151
+ open: boolean;
152
+ }) {
153
+ const origin = toolOrigin(capability.name);
136
154
 
137
155
  return (
138
- <aside className="sticky top-5 h-fit rounded-xl border bg-card p-5 shadow-sm">
139
- <h2 className="font-semibold">{i18n.t("tools.testTitle")}</h2>
140
- {!capability ? (
141
- <p className="mt-3 text-sm text-muted-foreground">{i18n.t("tools.select")}</p>
142
- ) : (
143
- <div className="mt-4 space-y-4">
144
- <div>
145
- <p className="text-sm font-medium">{capability.title ?? capability.name}</p>
146
- <p className="mt-1 font-mono text-xs text-muted-foreground">{capability.name}</p>
147
- </div>
148
- {capability.annotations.destructiveHint && (
149
- <p className="flex gap-2 rounded-md bg-destructive/10 p-3 text-xs text-destructive">
150
- <AlertTriangle aria-hidden="true" className="size-4 shrink-0" />
151
- {i18n.t("tools.destructive")}
152
- </p>
153
- )}
154
- {!safeToTest && (
155
- <p className="flex gap-2 rounded-md bg-muted p-3 text-xs text-muted-foreground">
156
- <ShieldAlert aria-hidden="true" className="size-4 shrink-0" />
157
- {i18n.t("tools.testUnsafe")}
158
- </p>
159
- )}
160
- <label className="block text-xs font-medium">
161
- {i18n.t("tools.arguments")}
162
- <textarea
163
- value={argumentsText}
164
- onChange={(event) => setArgumentsText(event.target.value)}
165
- rows={9}
166
- spellCheck={false}
167
- className="mt-2 w-full resize-y rounded-md border bg-background p-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
168
- />
169
- </label>
170
- {parseError && <p className="text-xs text-destructive">{i18n.t("tools.invalidJson")}</p>}
171
- <button
172
- type="button"
173
- disabled={test.isPending || !safeToTest}
174
- onClick={() => test.mutate()}
175
- className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
176
- >
177
- <Play aria-hidden="true" className="size-4" />
178
- {i18n.t("tools.runTest")}
179
- </button>
180
- {test.data && (
181
- <pre className="max-h-80 overflow-auto rounded-md bg-muted p-3 text-xs">
182
- {JSON.stringify(test.data, null, 2)}
183
- </pre>
156
+ <Collapsible defaultOpen={open}>
157
+ <CollapsibleTrigger className="group flex w-full items-start gap-4 px-5 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
158
+ <ChevronRight
159
+ aria-hidden="true"
160
+ className="mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90"
161
+ />
162
+ <span className="min-w-0 flex-1">
163
+ <span className="flex flex-wrap items-center gap-2">
164
+ <span className="text-sm font-medium">{capability.title ?? capability.name}</span>
165
+ <span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
166
+ <span className="sr-only">{i18n.t("tools.origin")}: </span>
167
+ {origin ?? i18n.t("tools.originPortal")}
168
+ </span>
169
+ {capability.annotations.destructiveHint && (
170
+ <span className="inline-flex items-center gap-1.5 rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive">
171
+ <AlertTriangle aria-hidden="true" className="size-3.5" />
172
+ {i18n.t("tools.destructiveShort")}
173
+ </span>
174
+ )}
175
+ </span>
176
+ {/* The namespaced name is what a flow references, so it stays readable — but only where
177
+ it is not already the heading of the row. */}
178
+ {capability.title && (
179
+ <span className="mt-1 block truncate font-mono text-xs text-muted-foreground">
180
+ {capability.name}
181
+ </span>
184
182
  )}
185
- {test.isError && !parseError && (
186
- <p className="text-xs text-destructive">{i18n.t("common.unavailable")}</p>
183
+ {capability.description && (
184
+ <span className="mt-2 block text-xs text-muted-foreground">
185
+ {capability.description}
186
+ </span>
187
187
  )}
188
- </div>
189
- )}
190
- </aside>
188
+ </span>
189
+ </CollapsibleTrigger>
190
+ <CollapsibleContent className="space-y-3 px-5 pb-4 pl-13">
191
+ <Schema label={i18n.t("tools.inputSchema")} value={capability.inputSchema} />
192
+ {capability.outputSchema && (
193
+ <Schema label={i18n.t("tools.outputSchema")} value={capability.outputSchema} />
194
+ )}
195
+ </CollapsibleContent>
196
+ </Collapsible>
197
+ );
198
+ }
199
+
200
+ function Schema({ label, value }: { label: string; value: Record<string, unknown> }) {
201
+ return (
202
+ <div>
203
+ <p className="text-xs font-medium">{label}</p>
204
+ <pre className="mt-1 max-h-80 overflow-auto rounded-md bg-muted p-3 text-xs">
205
+ {JSON.stringify(value, null, 2)}
206
+ </pre>
207
+ </div>
191
208
  );
192
209
  }