@anchrd/intel-ui 0.8.2 → 0.8.4
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/package.json +1 -1
- package/src/app/app-tree/app-tree.tsx +2 -10
- package/src/data/intel-data-provider/intel-data-provider.ts +11 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +4 -0
- package/src/folder-contents/folder-contents.tsx +85 -48
- package/src/i18n/en.json +5 -2
- package/src/kind-icon.ts +19 -0
- package/src/resource-menu/resource-menu.tsx +41 -20
- package/src/tools/tools.tsx +108 -25
package/package.json
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
FileText,
|
|
7
7
|
Folder,
|
|
8
8
|
FolderOpen,
|
|
9
|
-
Paperclip,
|
|
10
9
|
Plus,
|
|
11
10
|
Table,
|
|
12
11
|
Upload,
|
|
@@ -36,6 +35,7 @@ import {
|
|
|
36
35
|
} from "@/components/ui/sidebar";
|
|
37
36
|
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
38
37
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
38
|
+
import { kindIcons } from "@/kind-icon.ts";
|
|
39
39
|
import { Modal } from "@/modal/modal.tsx";
|
|
40
40
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
41
41
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
@@ -43,14 +43,6 @@ import { selectedFrom } from "@/router/selection-search.ts";
|
|
|
43
43
|
type NewKind = "folder" | "document" | "table" | "flow";
|
|
44
44
|
type Creating = { parentId: string | null; kind: NewKind };
|
|
45
45
|
|
|
46
|
-
const icons = {
|
|
47
|
-
folder: Folder,
|
|
48
|
-
document: FileText,
|
|
49
|
-
attachment: Paperclip,
|
|
50
|
-
table: Table,
|
|
51
|
-
flow: Workflow,
|
|
52
|
-
} as const;
|
|
53
|
-
|
|
54
46
|
// Attachments are inlined as base64, so the browser holds the file twice while it uploads.
|
|
55
47
|
const MaxUploadBytes = 15_000_000;
|
|
56
48
|
|
|
@@ -385,7 +377,7 @@ export function AppTree() {
|
|
|
385
377
|
const expandable = entry.openable;
|
|
386
378
|
const isOpen =
|
|
387
379
|
expandable && expanded.some((open) => open.id === entry.id && open.type === level.type);
|
|
388
|
-
const Icon = isOpen && isFolder ? FolderOpen :
|
|
380
|
+
const Icon = isOpen && isFolder ? FolderOpen : kindIcons[entry.kind];
|
|
389
381
|
const area = entry.type === "flow" ? "/flows" : "/knowledge";
|
|
390
382
|
const isActive = location.select === entry.id && location.pathname === area;
|
|
391
383
|
// A row's plus files into that row's place: inside a folder, beside anything else.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AppendKnowledgeTableRowsInput,
|
|
3
3
|
AppendKnowledgeTableRowsResult,
|
|
4
|
+
ArchiveFlowInput,
|
|
4
5
|
ArchiveKnowledgeNodeInput,
|
|
5
6
|
CompleteFlowRunStepInput,
|
|
6
7
|
CreateFlowInput,
|
|
@@ -159,6 +160,9 @@ export function createIntelDataProvider(
|
|
|
159
160
|
const params = new URLSearchParams(
|
|
160
161
|
query.parentId === undefined ? {} : { parentId: query.parentId ?? "" },
|
|
161
162
|
);
|
|
163
|
+
// Same rule as `parentId`: only the asked-for case is sent. The default is the server's, and a
|
|
164
|
+
// provider that spells it out anyway would have to be changed whenever the server's is.
|
|
165
|
+
if (query.includeArchived) params.set("includeArchived", "true");
|
|
162
166
|
const search = params.toString();
|
|
163
167
|
return await request(`/flows${search ? `?${search}` : ""}`, FlowList);
|
|
164
168
|
}
|
|
@@ -366,6 +370,13 @@ export function createIntelDataProvider(
|
|
|
366
370
|
body: JSON.stringify(parsed),
|
|
367
371
|
});
|
|
368
372
|
},
|
|
373
|
+
async archiveFlow(input) {
|
|
374
|
+
const parsed = ArchiveFlowInput.parse(input);
|
|
375
|
+
return await request(`/flows/${encodeURIComponent(parsed.flowId)}/archive`, Flow, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
body: JSON.stringify(parsed),
|
|
378
|
+
});
|
|
379
|
+
},
|
|
369
380
|
async saveFlow(input) {
|
|
370
381
|
const parsed = SaveFlowVersionInput.parse(input);
|
|
371
382
|
return await request(`/flows/${encodeURIComponent(parsed.flowId)}/versions`, FlowDocument, {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AppendKnowledgeTableRowsInput,
|
|
3
3
|
AppendKnowledgeTableRowsResult,
|
|
4
|
+
ArchiveFlowInput,
|
|
4
5
|
ArchiveKnowledgeNodeInput,
|
|
5
6
|
CompleteFlowRunStepInput,
|
|
6
7
|
CreateFlowInput,
|
|
@@ -117,6 +118,9 @@ export interface IntelDataProvider {
|
|
|
117
118
|
getFlowRequirements(flowId: string): Promise<FlowRequirements>;
|
|
118
119
|
createFlow(input: CreateFlowInput): Promise<Flow>;
|
|
119
120
|
updateFlow(input: UpdateFlowInput): Promise<Flow>;
|
|
121
|
+
// Archive a flow or take it back out. An archived flow leaves the tree, stops being startable and
|
|
122
|
+
// stops resolving as another flow's callee; its versions stay untouched.
|
|
123
|
+
archiveFlow(input: ArchiveFlowInput): Promise<Flow>;
|
|
120
124
|
saveFlow(input: SaveFlowVersionInput): Promise<FlowDocument>;
|
|
121
125
|
// Which version each sub-flow call will take once published, and which of them publishing
|
|
122
126
|
// freezes. Read before publishing, so the author agrees to the pins rather than discovering them.
|
|
@@ -10,15 +10,17 @@ import {
|
|
|
10
10
|
TableRow,
|
|
11
11
|
} from "@/components/ui/table.tsx";
|
|
12
12
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
13
|
+
import { kindIcons } from "@/kind-icon.ts";
|
|
14
|
+
import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
|
|
13
15
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
17
|
+
// The same record the menu is handed everywhere else, read off a level row. A re-labelling, never a
|
|
18
|
+
// second source of truth — a rename works on the whole record, not on a summary of it.
|
|
19
|
+
function targetOf(entry: TreeEntry): ResourceTarget {
|
|
20
|
+
return entry.type === "flow"
|
|
21
|
+
? { type: "flow", flow: entry.flow }
|
|
22
|
+
: { type: "knowledge", node: entry.node };
|
|
23
|
+
}
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* A folder's own screen: what lies directly in it, as a table.
|
|
@@ -27,7 +29,13 @@ const icons: Record<TreeEntry["kind"], string> = {
|
|
|
27
29
|
* `listTreeChildren`. Not a second call and not a second cache entry: two readers of one folder that
|
|
28
30
|
* fetch separately show it differently the moment one of them is stale, and that is the kind of
|
|
29
31
|
* difference a customer finds before anybody here does. The other half of the bargain is free —
|
|
30
|
-
* every invalidation the tree already does after a create, a move or an archive lands here too
|
|
32
|
+
* every invalidation the tree already does after a create, a move or an archive lands here too, and
|
|
33
|
+
* that now includes the ones this screen's own menu makes: `ResourceMenu` invalidates
|
|
34
|
+
* `["tree", parentId]`, which is this level.
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ There is no "Kind" column since issue 101. The icon says what a row is, in the same picture the
|
|
37
|
+
* sidebar draws two centimetres to its left, and `sr-only` says it in words for everyone who does
|
|
38
|
+
* not read pictures. A column repeating the icon in prose costs the width the title needs.
|
|
31
39
|
*/
|
|
32
40
|
export function FolderContents({ folderId }: { folderId: string }) {
|
|
33
41
|
const { data, i18n } = useIntelRouterContext();
|
|
@@ -61,46 +69,75 @@ export function FolderContents({ folderId }: { folderId: string }) {
|
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
return (
|
|
64
|
-
<div className="min-h-0 flex-1 overflow-y-auto p-
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
<
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
72
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-6">
|
|
73
|
+
<div className="overflow-hidden rounded-lg border bg-card">
|
|
74
|
+
<Table>
|
|
75
|
+
<TableHeader className="[&_tr]:bg-muted/40">
|
|
76
|
+
<TableRow className="hover:bg-transparent">
|
|
77
|
+
<TableHead className="h-9 px-4 text-xs tracking-wide uppercase">
|
|
78
|
+
{i18n.t("common.title")}
|
|
79
|
+
</TableHead>
|
|
80
|
+
<TableHead className="h-9 px-4 text-right text-xs tracking-wide uppercase">
|
|
81
|
+
{i18n.t("knowledge.changed")}
|
|
82
|
+
</TableHead>
|
|
83
|
+
{/* A `<th>` with nothing in it leaves the column unnamed for anyone reading the table
|
|
84
|
+
by its headers. The word is there; only the eye is spared it. */}
|
|
85
|
+
<TableHead className="h-9 w-14">
|
|
86
|
+
<span className="sr-only">{i18n.t("knowledge.rowActions")}</span>
|
|
87
|
+
</TableHead>
|
|
88
|
+
</TableRow>
|
|
89
|
+
</TableHeader>
|
|
90
|
+
<TableBody>
|
|
91
|
+
{entries.map((entry) => {
|
|
92
|
+
const changed = entry.type === "flow" ? entry.flow.updatedAt : entry.node.updatedAt;
|
|
93
|
+
const Icon = kindIcons[entry.kind];
|
|
94
|
+
return (
|
|
95
|
+
<TableRow key={entry.id} className="group/row hover:bg-muted">
|
|
96
|
+
{/* ⚠️ The button carries the whole cell rather than the cell carrying an onClick.
|
|
97
|
+
A row that only reacts to a mouse is a row nobody can reach with a keyboard, and
|
|
98
|
+
a `<tr onClick>` has no name to read out either. */}
|
|
99
|
+
<TableCell className="p-0">
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={() =>
|
|
103
|
+
void navigate({
|
|
104
|
+
to: entry.type === "flow" ? "/flows" : "/knowledge",
|
|
105
|
+
search: { select: entry.id },
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
className="flex w-full min-w-0 items-center gap-3 px-4 py-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
|
109
|
+
>
|
|
110
|
+
{/* The icon gets a tile of its own so five kinds under one another read as a
|
|
111
|
+
column, instead of five loose glyphs at five different widths. */}
|
|
112
|
+
<span
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
className="grid size-8 shrink-0 place-items-center rounded-md border bg-muted/50 text-muted-foreground transition-colors group-hover/row:bg-accent group-hover/row:text-accent-foreground"
|
|
115
|
+
>
|
|
116
|
+
<Icon className="size-4" />
|
|
117
|
+
</span>
|
|
118
|
+
<span className="min-w-0 truncate font-medium group-hover/row:underline">
|
|
119
|
+
{entry.title}
|
|
120
|
+
</span>
|
|
121
|
+
{/* The icon carries the type on screen; this carries it everywhere else, and
|
|
122
|
+
it is why a flow needs no suffix in its name to be told from a document. */}
|
|
123
|
+
<span className="sr-only">{i18n.t(`knowledge.kind.${entry.kind}`)}</span>
|
|
124
|
+
</button>
|
|
125
|
+
</TableCell>
|
|
126
|
+
<TableCell className="px-4 text-right text-sm text-muted-foreground tabular-nums">
|
|
127
|
+
{new Date(changed).toLocaleDateString(i18n.locale)}
|
|
128
|
+
</TableCell>
|
|
129
|
+
{/* ⚠️ The same menu as the title line of the open thing, not a shorter one built
|
|
130
|
+
for here. #58 collected every action in one place so that place is always the
|
|
131
|
+
same; a folder listing offering three of the six would be a second place. */}
|
|
132
|
+
<TableCell className="px-3 text-right">
|
|
133
|
+
<ResourceMenu target={targetOf(entry)} variant="row" />
|
|
134
|
+
</TableCell>
|
|
135
|
+
</TableRow>
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
138
|
+
</TableBody>
|
|
139
|
+
</Table>
|
|
140
|
+
</div>
|
|
104
141
|
</div>
|
|
105
142
|
);
|
|
106
143
|
}
|
package/src/i18n/en.json
CHANGED
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"resource.menu": "More actions for {title}",
|
|
73
73
|
"resource.rename": "Rename",
|
|
74
74
|
"resource.renameTitle": "Rename {title}",
|
|
75
|
+
"resource.archive": "Archive",
|
|
75
76
|
"resource.conflict": "It was not changed: somebody else changed this entry first. Reload it and try again.",
|
|
76
77
|
"resource.forbidden": "It was not changed: you may not change this entry.",
|
|
77
78
|
"resource.failed": "The change was not saved. Reload the latest version and try again.",
|
|
@@ -85,8 +86,8 @@
|
|
|
85
86
|
"knowledge.select": "Select a document or folder to work with it.",
|
|
86
87
|
"knowledge.folderEmpty": "Nothing in this folder yet. Create something with the plus in the sidebar.",
|
|
87
88
|
"knowledge.folderFailed": "This folder could not be read.",
|
|
88
|
-
"knowledge.kind": "Kind",
|
|
89
89
|
"knowledge.changed": "Changed",
|
|
90
|
+
"knowledge.rowActions": "Actions",
|
|
90
91
|
"knowledge.kind.folder": "Folder",
|
|
91
92
|
"knowledge.kind.document": "Document",
|
|
92
93
|
"knowledge.kind.attachment": "Upload",
|
|
@@ -114,7 +115,6 @@
|
|
|
114
115
|
"knowledge.shareUnreadableHint": "Nothing is blocked. A run of those flows will simply stop at that step for them.",
|
|
115
116
|
"knowledge.versions": "Version history",
|
|
116
117
|
"knowledge.version": "Version {sequence}",
|
|
117
|
-
"knowledge.archive": "Archive",
|
|
118
118
|
"view.showGraph": "Show this level as a graph",
|
|
119
119
|
"view.showEditor": "Back to the editor",
|
|
120
120
|
"view.showRuns": "Show this flow's runs",
|
|
@@ -157,6 +157,9 @@
|
|
|
157
157
|
"tools.inputSchema": "Input schema",
|
|
158
158
|
"tools.outputSchema": "Result schema",
|
|
159
159
|
"tools.destructiveShort": "Destructive",
|
|
160
|
+
"tools.destructiveCount": "{count} destructive",
|
|
161
|
+
"tools.toolCount": "{count} tools",
|
|
162
|
+
"tools.toolCountOne": "1 tool",
|
|
160
163
|
"tools.liveNote": "This list is a live query with your own portal access. Intel stores neither the tools nor who may use them; the portal decides both.",
|
|
161
164
|
"tools.arguments": "Arguments",
|
|
162
165
|
"tools.invalidJson": "The arguments must be a JSON object in curly braces; a list or a single value will not work.",
|
package/src/kind-icon.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { FileText, Folder, type LucideIcon, Paperclip, Table, Workflow } from "lucide-react";
|
|
2
|
+
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one place a kind becomes a picture.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ Two screens draw the same row — the sidebar tree and the folder's own table — and they must
|
|
8
|
+
* draw it the same. A second map beside this one drifts the first time a kind is added: the tree
|
|
9
|
+
* would show the new icon and the folder screen the old one, for the same thing on the same screen.
|
|
10
|
+
* The picture is also only half the answer; both places still name the kind for a reader who cannot
|
|
11
|
+
* see it (`tree.kind.*`, `knowledge.kind.*`), because an icon has no text.
|
|
12
|
+
*/
|
|
13
|
+
export const kindIcons: Record<TreeEntry["kind"], LucideIcon> = {
|
|
14
|
+
folder: Folder,
|
|
15
|
+
document: FileText,
|
|
16
|
+
attachment: Paperclip,
|
|
17
|
+
table: Table,
|
|
18
|
+
flow: Workflow,
|
|
19
|
+
};
|
|
@@ -102,11 +102,14 @@ export function resourceErrorKey(error: unknown): string {
|
|
|
102
102
|
/**
|
|
103
103
|
* The three-dot menu.
|
|
104
104
|
*
|
|
105
|
-
* `variant` is only how the trigger is painted. `title` is
|
|
106
|
-
* the end of a title line, put there by `TitleRow` and by nobody else, so it is
|
|
107
|
-
* thing in that line (#53). `row` is the
|
|
108
|
-
* in it takes focus — and
|
|
109
|
-
*
|
|
105
|
+
* `variant` is only how the trigger is painted, never which entries it holds. `title` is a plain
|
|
106
|
+
* icon button at the end of a title line, put there by `TitleRow` and by nobody else, so it is
|
|
107
|
+
* always the last thing in that line (#53). `row` is the form for a row in a list — quiet until the
|
|
108
|
+
* row is hovered or something in it takes focus — and since issue 101 that list is the folder table.
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ `row` needs a `group/row` on the row it sits in, or it stays invisible to the mouse: the
|
|
111
|
+
* reveal is `group-hover/row`, and a group that is not there never hovers. Below `md` and under
|
|
112
|
+
* focus it is shown regardless, because hover is not a gesture every input has.
|
|
110
113
|
*/
|
|
111
114
|
export function ResourceMenu({
|
|
112
115
|
target,
|
|
@@ -122,6 +125,7 @@ export function ResourceMenu({
|
|
|
122
125
|
const [sharing, setSharing] = useState(false);
|
|
123
126
|
const [linksOpen, setLinksOpen] = useState(false);
|
|
124
127
|
const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
|
|
128
|
+
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
125
129
|
// ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
|
|
126
130
|
// the name. #27 gave moving a second, equal route through a folder picker, and that route lived in
|
|
127
131
|
// the tree row's menu. With the row menu gone (#58) it lives here, or it does not exist.
|
|
@@ -186,9 +190,20 @@ export function ResourceMenu({
|
|
|
186
190
|
},
|
|
187
191
|
});
|
|
188
192
|
|
|
193
|
+
// Both records take the same conflict route as a rename: `baseUpdatedAt` from the row that was on
|
|
194
|
+
// screen, so archiving something somebody else has just changed is refused rather than silently
|
|
195
|
+
// applied to a different state than the one that was read.
|
|
189
196
|
const archive = useMutation({
|
|
190
197
|
mutationFn: async () => {
|
|
191
|
-
if (target.type === "flow")
|
|
198
|
+
if (target.type === "flow") {
|
|
199
|
+
await data.archiveFlow({
|
|
200
|
+
flowId: target.flow.id,
|
|
201
|
+
baseUpdatedAt: target.flow.updatedAt,
|
|
202
|
+
archived: true,
|
|
203
|
+
idempotencyKey: crypto.randomUUID(),
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
192
207
|
await data.archiveKnowledge({
|
|
193
208
|
nodeId: target.node.id,
|
|
194
209
|
baseUpdatedAt: target.node.updatedAt,
|
|
@@ -200,7 +215,14 @@ export function ResourceMenu({
|
|
|
200
215
|
await refresh();
|
|
201
216
|
// The row is gone from the tree. If it was also what the screen was showing, the screen has
|
|
202
217
|
// nothing left to show either.
|
|
203
|
-
|
|
218
|
+
//
|
|
219
|
+
// ⚠️ It drops the selection where it stands rather than always landing on /knowledge: a flow
|
|
220
|
+
// archived from the flow screen would otherwise throw the reader onto another screen for no
|
|
221
|
+
// reason they could see. A flow reached through the shared tree stays on /knowledge, which is
|
|
222
|
+
// where it was being read.
|
|
223
|
+
if (selected === id) {
|
|
224
|
+
await navigate({ to: pathname === "/flows" ? "/flows" : "/knowledge", search: {} });
|
|
225
|
+
}
|
|
204
226
|
},
|
|
205
227
|
});
|
|
206
228
|
|
|
@@ -218,7 +240,7 @@ export function ResourceMenu({
|
|
|
218
240
|
aria-label={i18n.t("resource.menu", { title })}
|
|
219
241
|
className={
|
|
220
242
|
variant === "row"
|
|
221
|
-
? "shrink-0 rounded-md
|
|
243
|
+
? "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
|
|
222
244
|
: "inline-flex size-8 shrink-0 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
223
245
|
}
|
|
224
246
|
>
|
|
@@ -257,18 +279,17 @@ export function ResourceMenu({
|
|
|
257
279
|
{i18n.t("knowledge.share")}
|
|
258
280
|
</DropdownMenuItem>
|
|
259
281
|
) : null}
|
|
260
|
-
{
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
) : null}
|
|
282
|
+
{/* A flow is archived like a document (issue 109). It keeps its versions and its runs; what it
|
|
283
|
+
loses is reach — it leaves the tree, cannot be started, and stops resolving as another
|
|
284
|
+
flow's callee. */}
|
|
285
|
+
<DropdownMenuSeparator />
|
|
286
|
+
<DropdownMenuItem
|
|
287
|
+
onSelect={() => archive.mutate()}
|
|
288
|
+
className="text-destructive focus:text-destructive"
|
|
289
|
+
>
|
|
290
|
+
<Archive aria-hidden="true" />
|
|
291
|
+
{i18n.t("resource.archive")}
|
|
292
|
+
</DropdownMenuItem>
|
|
272
293
|
</DropdownMenuContent>
|
|
273
294
|
</DropdownMenu>
|
|
274
295
|
{/* The menu closes on selection, so a refusal has nowhere to live inside it. It stands over
|
package/src/tools/tools.tsx
CHANGED
|
@@ -19,6 +19,30 @@ function toolOrigin(name: string): string | null {
|
|
|
19
19
|
return boundary > 0 ? name.slice(0, boundary) : null;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
interface ToolGroup {
|
|
23
|
+
// `null` is a tool the portal answers without a namespace: its origin is the portal itself.
|
|
24
|
+
origin: string | null;
|
|
25
|
+
items: ToolCapability[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The catalog arrives as one flat list of a hundred-odd names, and the namespace is the only thing
|
|
29
|
+
// that says which server a tool belongs to. Grouping is therefore not decoration — it is the one
|
|
30
|
+
// piece of structure the wire carries, made visible (#99).
|
|
31
|
+
//
|
|
32
|
+
// The order is the portal's own: a group appears where its first tool appears. Sorting here would
|
|
33
|
+
// replace an answer with a preference, and the portal already returns a server's tools together.
|
|
34
|
+
function groupByOrigin(items: ToolCapability[]): ToolGroup[] {
|
|
35
|
+
const groups = new Map<string, ToolGroup>();
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
const origin = toolOrigin(item.name);
|
|
38
|
+
const key = origin ?? "";
|
|
39
|
+
const group = groups.get(key) ?? { origin, items: [] };
|
|
40
|
+
group.items.push(item);
|
|
41
|
+
groups.set(key, group);
|
|
42
|
+
}
|
|
43
|
+
return [...groups.values()];
|
|
44
|
+
}
|
|
45
|
+
|
|
22
46
|
// Signed in to Intel is signed in to the portal, so the sign-in is attempted at most once per
|
|
23
47
|
// visit. A second attempt after a refusal would be a redirect loop with an unchanging answer, and
|
|
24
48
|
// the marker in the URL only survives until the next navigation — the browser session remembers it
|
|
@@ -140,19 +164,16 @@ export function Tools() {
|
|
|
140
164
|
/>
|
|
141
165
|
) : (
|
|
142
166
|
<>
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
))}
|
|
154
|
-
</ul>
|
|
155
|
-
</section>
|
|
167
|
+
<div className="space-y-3">
|
|
168
|
+
{groupByOrigin(catalog.data.items).map((group) => (
|
|
169
|
+
<ServerGroup
|
|
170
|
+
key={group.origin ?? ""}
|
|
171
|
+
group={group}
|
|
172
|
+
i18n={i18n}
|
|
173
|
+
requested={requested}
|
|
174
|
+
/>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
156
177
|
<p className="text-xs text-muted-foreground">{i18n.t("tools.liveNote")}</p>
|
|
157
178
|
</>
|
|
158
179
|
)}
|
|
@@ -187,6 +208,74 @@ function Notice({
|
|
|
187
208
|
);
|
|
188
209
|
}
|
|
189
210
|
|
|
211
|
+
// One server, one box, closed until asked. Closed is the point: a hundred rows spread over a
|
|
212
|
+
// handful of servers is a list nobody reads, and which server a tool speaks to is the first thing
|
|
213
|
+
// somebody wants to know about it (#99).
|
|
214
|
+
function ServerGroup({
|
|
215
|
+
group,
|
|
216
|
+
i18n,
|
|
217
|
+
requested,
|
|
218
|
+
}: {
|
|
219
|
+
group: ToolGroup;
|
|
220
|
+
i18n: I18n;
|
|
221
|
+
requested: string | null;
|
|
222
|
+
}) {
|
|
223
|
+
// A hit from the header search names a tool, not a server, and the box it sits in has to open
|
|
224
|
+
// with it — otherwise Enter on a hit lands on a screen where nothing happened.
|
|
225
|
+
const holdsRequested = group.items.some((item) => item.name === requested);
|
|
226
|
+
const destructive = group.items.filter((item) => item.annotations.destructiveHint).length;
|
|
227
|
+
const label = group.origin ?? i18n.t("tools.originPortal");
|
|
228
|
+
const count =
|
|
229
|
+
group.items.length === 1
|
|
230
|
+
? i18n.t("tools.toolCountOne")
|
|
231
|
+
: i18n.t("tools.toolCount", { count: group.items.length });
|
|
232
|
+
// The badges beside the name read as one run-on word when a screen reader concatenates them
|
|
233
|
+
// ("wiki1 tool"), so the control says its own name instead of being read off its contents.
|
|
234
|
+
const spoken = [`${i18n.t("tools.origin")}: ${label}`, count];
|
|
235
|
+
if (destructive > 0) spoken.push(i18n.t("tools.destructiveCount", { count: destructive }));
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<Collapsible defaultOpen={holdsRequested} asChild>
|
|
239
|
+
<section className="overflow-hidden rounded-xl border bg-card shadow-sm">
|
|
240
|
+
<CollapsibleTrigger
|
|
241
|
+
aria-label={spoken.join(", ")}
|
|
242
|
+
className="group flex w-full items-center gap-3 px-5 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
243
|
+
>
|
|
244
|
+
<ChevronRight
|
|
245
|
+
aria-hidden="true"
|
|
246
|
+
className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90"
|
|
247
|
+
/>
|
|
248
|
+
<span className="min-w-0 flex-1">
|
|
249
|
+
<span className="block truncate text-sm font-semibold">{label}</span>
|
|
250
|
+
</span>
|
|
251
|
+
{/* Closed, the box hides every destructive badge inside it. The count says so on the lid
|
|
252
|
+
rather than making somebody open each server to find out. */}
|
|
253
|
+
{destructive > 0 && (
|
|
254
|
+
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive">
|
|
255
|
+
<AlertTriangle aria-hidden="true" className="size-3.5" />
|
|
256
|
+
{i18n.t("tools.destructiveCount", { count: destructive })}
|
|
257
|
+
</span>
|
|
258
|
+
)}
|
|
259
|
+
<span className="shrink-0 text-xs text-muted-foreground">{count}</span>
|
|
260
|
+
</CollapsibleTrigger>
|
|
261
|
+
<CollapsibleContent>
|
|
262
|
+
<ul className="divide-y border-t">
|
|
263
|
+
{group.items.map((capability) => (
|
|
264
|
+
<li key={capability.name}>
|
|
265
|
+
<ToolEntry
|
|
266
|
+
capability={capability}
|
|
267
|
+
i18n={i18n}
|
|
268
|
+
open={capability.name === requested}
|
|
269
|
+
/>
|
|
270
|
+
</li>
|
|
271
|
+
))}
|
|
272
|
+
</ul>
|
|
273
|
+
</CollapsibleContent>
|
|
274
|
+
</section>
|
|
275
|
+
</Collapsible>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
190
279
|
// One tool, one disclosure. The row is the only control in the list: it opens the schema and does
|
|
191
280
|
// nothing else — enabling, sharing or permitting a tool happens in the portal, never here.
|
|
192
281
|
function ToolEntry({
|
|
@@ -198,11 +287,9 @@ function ToolEntry({
|
|
|
198
287
|
i18n: I18n;
|
|
199
288
|
open: boolean;
|
|
200
289
|
}) {
|
|
201
|
-
const origin = toolOrigin(capability.name);
|
|
202
|
-
|
|
203
290
|
return (
|
|
204
291
|
<Collapsible defaultOpen={open}>
|
|
205
|
-
<CollapsibleTrigger className="group flex w-full items-start gap-4 px-5 py-
|
|
292
|
+
<CollapsibleTrigger className="group flex w-full items-start gap-4 px-5 py-3 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
|
|
206
293
|
<ChevronRight
|
|
207
294
|
aria-hidden="true"
|
|
208
295
|
className="mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90"
|
|
@@ -210,10 +297,6 @@ function ToolEntry({
|
|
|
210
297
|
<span className="min-w-0 flex-1">
|
|
211
298
|
<span className="flex flex-wrap items-center gap-2">
|
|
212
299
|
<span className="text-sm font-medium">{capability.title ?? capability.name}</span>
|
|
213
|
-
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|
214
|
-
<span className="sr-only">{i18n.t("tools.origin")}: </span>
|
|
215
|
-
{origin ?? i18n.t("tools.originPortal")}
|
|
216
|
-
</span>
|
|
217
300
|
{capability.annotations.destructiveHint && (
|
|
218
301
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive">
|
|
219
302
|
<AlertTriangle aria-hidden="true" className="size-3.5" />
|
|
@@ -228,14 +311,14 @@ function ToolEntry({
|
|
|
228
311
|
{capability.name}
|
|
229
312
|
</span>
|
|
230
313
|
)}
|
|
231
|
-
{capability.description && (
|
|
232
|
-
<span className="mt-2 block text-xs text-muted-foreground">
|
|
233
|
-
{capability.description}
|
|
234
|
-
</span>
|
|
235
|
-
)}
|
|
236
314
|
</span>
|
|
237
315
|
</CollapsibleTrigger>
|
|
316
|
+
{/* ⚠️ The description belongs here, not in the closed row. Held open it was the whole reason
|
|
317
|
+
the list could not be scanned: a paragraph per tool, a hundred times over. */}
|
|
238
318
|
<CollapsibleContent className="space-y-3 px-5 pb-4 pl-13">
|
|
319
|
+
{capability.description && (
|
|
320
|
+
<p className="text-xs text-muted-foreground">{capability.description}</p>
|
|
321
|
+
)}
|
|
239
322
|
<Schema label={i18n.t("tools.inputSchema")} value={capability.inputSchema} />
|
|
240
323
|
{capability.outputSchema && (
|
|
241
324
|
<Schema label={i18n.t("tools.outputSchema")} value={capability.outputSchema} />
|