@anchrd/intel-ui 0.5.0 → 0.6.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/package.json +1 -1
- package/src/app/action-slot/action-slot.tsx +4 -0
- package/src/app/app-tree/app-tree.tsx +21 -120
- package/src/app/app.tsx +15 -4
- package/src/app/tree-move/tree-move.tsx +135 -1
- package/src/data/intel-data-provider/intel-data-provider.ts +15 -3
- package/src/data/intel-data-provider/intel-data-provider.types.ts +4 -1
- package/src/flows/flows.tsx +37 -42
- package/src/flows/node-palette/node-palette.tsx +33 -7
- package/src/i18n/en.json +3 -5
- package/src/knowledge/knowledge.tsx +45 -35
- package/src/knowledge-table/knowledge-table.tsx +23 -11
- package/src/resource-menu/resource-menu.tsx +57 -22
- package/src/title-row/title-row.tsx +49 -0
- package/src/tools/tools.tsx +57 -38
package/package.json
CHANGED
|
@@ -10,6 +10,10 @@ import { createPortal } from "react-dom";
|
|
|
10
10
|
// ⚠️ The slot lives above the component that fills it, so it is not in the document while that
|
|
11
11
|
// component first renders. Looking it up in an effect costs one extra render and is the only order
|
|
12
12
|
// that works; reading it during render finds nothing on the first paint.
|
|
13
|
+
//
|
|
14
|
+
// ⚠️ A portal only ever appends, so the slot decides where its content sits and mount order decides
|
|
15
|
+
// nothing. That is why the shell gives the screens a box of their own next to the search rather
|
|
16
|
+
// than one shared list (see `app.tsx`, #56).
|
|
13
17
|
export function ActionSlot({
|
|
14
18
|
name = "header-actions",
|
|
15
19
|
children,
|
|
@@ -15,10 +15,11 @@ import {
|
|
|
15
15
|
import { useRef, useState } from "react";
|
|
16
16
|
import {
|
|
17
17
|
type MoveDestination,
|
|
18
|
-
MoveDialog,
|
|
19
18
|
moveErrorKey,
|
|
20
19
|
moveVerdict,
|
|
21
20
|
parentOf,
|
|
21
|
+
treeLevelKey,
|
|
22
|
+
useTreeMove,
|
|
22
23
|
} from "@/app/tree-move/tree-move.tsx";
|
|
23
24
|
import {
|
|
24
25
|
DropdownMenu,
|
|
@@ -36,7 +37,6 @@ import {
|
|
|
36
37
|
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
37
38
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
38
39
|
import { Modal } from "@/modal/modal.tsx";
|
|
39
|
-
import { ResourceMenu } from "@/resource-menu/resource-menu.tsx";
|
|
40
40
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
41
41
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
42
42
|
|
|
@@ -79,15 +79,7 @@ type Level = { id: string | null; type: "folder" | "flow" };
|
|
|
79
79
|
// One key per level. `null` is the root; every expanded row adds one of its own, and nothing else is
|
|
80
80
|
// ever asked for.
|
|
81
81
|
function levelKey(level: Level): readonly unknown[] {
|
|
82
|
-
return level.type === "flow" ? ["flow-calls", level.id] :
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
|
|
86
|
-
// plus on the moved row would still file into the folder it just left.
|
|
87
|
-
function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
|
|
88
|
-
return entry.type === "knowledge"
|
|
89
|
-
? { ...entry, node: { ...entry.node, parentId } }
|
|
90
|
-
: { ...entry, flow: { ...entry.flow, parentId } };
|
|
82
|
+
return level.type === "flow" ? ["flow-calls", level.id] : treeLevelKey(level.id);
|
|
91
83
|
}
|
|
92
84
|
|
|
93
85
|
export function AppTree() {
|
|
@@ -103,10 +95,14 @@ export function AppTree() {
|
|
|
103
95
|
// Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
|
|
104
96
|
// folder row. Three answers, because "the root" and "nothing" are not the same drop.
|
|
105
97
|
const [over, setOver] = useState<string | null | undefined>(undefined);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
98
|
+
// The drop's half of the move. The other half is the folder picker in the title line's menu, and
|
|
99
|
+
// both go through the same mutation (#58, `useTreeMove`) — the tree only adds what a tree can add,
|
|
100
|
+
// which is opening the folder the row just landed in.
|
|
101
|
+
const move = useTreeMove({
|
|
102
|
+
onMoved: (destination) => {
|
|
103
|
+
if (destination.id !== null) toggle({ id: destination.id, type: "folder" }, true);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
110
106
|
|
|
111
107
|
const location = useRouterState({
|
|
112
108
|
select: (state) => ({
|
|
@@ -251,86 +247,6 @@ export function AppTree() {
|
|
|
251
247
|
},
|
|
252
248
|
});
|
|
253
249
|
|
|
254
|
-
// ⚠️ Optimistic, never authoritative. The row is lifted out of one level and dropped into the
|
|
255
|
-
// other before the server answers, and every refusal — 403, `parent_not_folder`, `move_cycle`,
|
|
256
|
-
// `update_conflict` — puts both levels back exactly as they were and then re-reads them, so a
|
|
257
|
-
// rejected move cannot leave a row standing twice or nowhere at all.
|
|
258
|
-
const move = useMutation({
|
|
259
|
-
mutationFn: async ({
|
|
260
|
-
entry,
|
|
261
|
-
destination,
|
|
262
|
-
}: {
|
|
263
|
-
entry: TreeEntry;
|
|
264
|
-
destination: MoveDestination;
|
|
265
|
-
}) => {
|
|
266
|
-
// `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
|
|
267
|
-
// view can act on instead of an overwrite nobody notices.
|
|
268
|
-
if (entry.type === "flow") {
|
|
269
|
-
await data.updateFlow({
|
|
270
|
-
flowId: entry.id,
|
|
271
|
-
baseUpdatedAt: entry.flow.updatedAt,
|
|
272
|
-
parentId: destination.id,
|
|
273
|
-
idempotencyKey: crypto.randomUUID(),
|
|
274
|
-
});
|
|
275
|
-
} else {
|
|
276
|
-
await data.updateKnowledge({
|
|
277
|
-
nodeId: entry.id,
|
|
278
|
-
baseUpdatedAt: entry.node.updatedAt,
|
|
279
|
-
parentId: destination.id,
|
|
280
|
-
idempotencyKey: crypto.randomUUID(),
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
},
|
|
284
|
-
onMutate: async ({ entry, destination }) => {
|
|
285
|
-
const fromKey = levelKey({ id: parentOf(entry), type: "folder" });
|
|
286
|
-
const toKey = levelKey({ id: destination.id, type: "folder" });
|
|
287
|
-
await Promise.all([
|
|
288
|
-
queryClient.cancelQueries({ queryKey: fromKey }),
|
|
289
|
-
queryClient.cancelQueries({ queryKey: toKey }),
|
|
290
|
-
]);
|
|
291
|
-
const snapshot = [
|
|
292
|
-
[fromKey, queryClient.getQueryData<TreeEntry[]>(fromKey)],
|
|
293
|
-
[toKey, queryClient.getQueryData<TreeEntry[]>(toKey)],
|
|
294
|
-
] as const;
|
|
295
|
-
queryClient.setQueryData<TreeEntry[]>(fromKey, (current) =>
|
|
296
|
-
current?.filter((row) => row.id !== entry.id),
|
|
297
|
-
);
|
|
298
|
-
// A level nobody has opened stays unloaded: writing one here would show a folder's contents
|
|
299
|
-
// that were never read.
|
|
300
|
-
queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
|
|
301
|
-
current === undefined
|
|
302
|
-
? current
|
|
303
|
-
: [...current.filter((row) => row.id !== entry.id), withParent(entry, destination.id)],
|
|
304
|
-
);
|
|
305
|
-
return { snapshot };
|
|
306
|
-
},
|
|
307
|
-
onError: (_error, _variables, context) => {
|
|
308
|
-
for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
|
|
309
|
-
},
|
|
310
|
-
onSuccess: (_result, { destination }) => {
|
|
311
|
-
if (destination.id !== null) toggle({ id: destination.id, type: "folder" }, true);
|
|
312
|
-
},
|
|
313
|
-
onSettled: async (_result, _error, { entry, destination }) => {
|
|
314
|
-
await Promise.all([
|
|
315
|
-
queryClient.invalidateQueries({
|
|
316
|
-
queryKey: levelKey({ id: parentOf(entry), type: "folder" }),
|
|
317
|
-
}),
|
|
318
|
-
queryClient.invalidateQueries({
|
|
319
|
-
queryKey: levelKey({ id: destination.id, type: "folder" }),
|
|
320
|
-
}),
|
|
321
|
-
queryClient.invalidateQueries({
|
|
322
|
-
queryKey: [entry.type === "flow" ? "flows" : "knowledge-graph"],
|
|
323
|
-
}),
|
|
324
|
-
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
325
|
-
]);
|
|
326
|
-
},
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
function startMove(entry: TreeEntry, destination: MoveDestination | null) {
|
|
330
|
-
move.reset();
|
|
331
|
-
setMoving({ entry, initial: destination });
|
|
332
|
-
}
|
|
333
|
-
|
|
334
250
|
// What a drop on this target would do. `undefined` means nothing is being dragged, so the target
|
|
335
251
|
// is not a target at all.
|
|
336
252
|
function verdictFor(
|
|
@@ -374,7 +290,7 @@ export function AppTree() {
|
|
|
374
290
|
setDragged(null);
|
|
375
291
|
draggedRef.current = null;
|
|
376
292
|
if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
|
|
377
|
-
|
|
293
|
+
move.start(carried, target);
|
|
378
294
|
}
|
|
379
295
|
},
|
|
380
296
|
},
|
|
@@ -559,17 +475,12 @@ export function AppTree() {
|
|
|
559
475
|
else setCreating({ parentId: target, kind });
|
|
560
476
|
}}
|
|
561
477
|
/>
|
|
562
|
-
{/* ⚠️
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
be moved from, so it gets no move — and no menu, since renaming the shared flow from
|
|
569
|
-
under one of its callers would rename it for all of them without saying so. */}
|
|
570
|
-
{derived ? null : (
|
|
571
|
-
<ResourceMenu target={entry} variant="row" onMove={() => startMove(entry, null)} />
|
|
572
|
-
)}
|
|
478
|
+
{/* ⚠️ Nothing else. The three-dot menu stood here until #58 and does not any more: two
|
|
479
|
+
buttons per row put ten of them into a 240px column, and one then reads buttons instead
|
|
480
|
+
of titles — the tree is for reading. Every action it held is in the title line of the
|
|
481
|
+
thing itself, which every row opens by being clicked, including a folder, whose screen
|
|
482
|
+
is nothing but that line. Do not put a second one back here: the point of the menu is
|
|
483
|
+
that it is always in the same place, and two places are not one. */}
|
|
573
484
|
</div>
|
|
574
485
|
{/* The same rows one indent deeper: one row component for every depth, so the plus on the
|
|
575
486
|
fourth level is the same plus as on the first. The list is named after the row it hangs
|
|
@@ -598,7 +509,7 @@ export function AppTree() {
|
|
|
598
509
|
<button
|
|
599
510
|
type="button"
|
|
600
511
|
{...rootDrop.props}
|
|
601
|
-
onClick={() =>
|
|
512
|
+
onClick={() => move.start(dragged, { id: null, title: i18n.t("tree.move.root") })}
|
|
602
513
|
data-drop={rootDrop.verdict}
|
|
603
514
|
className={`mb-1 flex w-full items-center gap-1 rounded-md border border-dashed px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${
|
|
604
515
|
rootDrop.verdict === "ok"
|
|
@@ -622,7 +533,7 @@ export function AppTree() {
|
|
|
622
533
|
) : null}
|
|
623
534
|
{/* Four refusals, four sentences — and by the time one is read the row is already back where
|
|
624
535
|
it started, because the rollback happens in `onError` rather than here. */}
|
|
625
|
-
{move.
|
|
536
|
+
{move.error ? (
|
|
626
537
|
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
627
538
|
{i18n.t(moveErrorKey(move.error))}
|
|
628
539
|
</p>
|
|
@@ -666,17 +577,7 @@ export function AppTree() {
|
|
|
666
577
|
/>
|
|
667
578
|
</Modal>
|
|
668
579
|
) : null}
|
|
669
|
-
{
|
|
670
|
-
<MoveDialog
|
|
671
|
-
entry={moving.entry}
|
|
672
|
-
initial={moving.initial}
|
|
673
|
-
close={() => setMoving(null)}
|
|
674
|
-
submit={(destination) => {
|
|
675
|
-
setMoving(null);
|
|
676
|
-
move.mutate({ entry: moving.entry, destination });
|
|
677
|
-
}}
|
|
678
|
-
/>
|
|
679
|
-
) : null}
|
|
580
|
+
{move.dialog}
|
|
680
581
|
</SidebarGroup>
|
|
681
582
|
);
|
|
682
583
|
}
|
package/src/app/app.tsx
CHANGED
|
@@ -91,10 +91,21 @@ export function App() {
|
|
|
91
91
|
) : null}
|
|
92
92
|
</BreadcrumbList>
|
|
93
93
|
</Breadcrumb>
|
|
94
|
-
{/* The bar for area-owned actions. A screen renders into
|
|
95
|
-
search is the shell's own, because it has to open from every screen
|
|
96
|
-
to none of them. It stays
|
|
97
|
-
|
|
94
|
+
{/* The bar for area-owned actions. A screen renders into the inner box through
|
|
95
|
+
`ActionSlot`; search is the shell's own, because it has to open from every screen
|
|
96
|
+
alike and belongs to none of them. It stays last, against the right edge, and a
|
|
97
|
+
screen's actions line up to its left: the search is the one thing in this bar that is
|
|
98
|
+
on every screen, so it is the one whose place must not depend on what a screen happens
|
|
99
|
+
to bring. Pinned the other way round it was the constant that moved (#56).
|
|
100
|
+
|
|
101
|
+
⚠️ Two boxes rather than one list, and that is the whole of the mechanism: a portal
|
|
102
|
+
only ever appends to its container, so with the search a sibling of the portalled
|
|
103
|
+
actions a screen that mounts them later — Flows does, once a flow is selected — would
|
|
104
|
+
land to its right. Giving the screens a container of their own takes mount order out
|
|
105
|
+
of the question entirely. `data-slot="header-actions"` therefore names the inner box:
|
|
106
|
+
it is the name every `ActionSlot` looks up. */}
|
|
107
|
+
<div data-slot="header-bar" className="ml-auto flex items-center gap-2">
|
|
108
|
+
<div data-slot="header-actions" className="flex items-center gap-2" />
|
|
98
109
|
<HeaderSearch />
|
|
99
110
|
</div>
|
|
100
111
|
</header>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useQuery } from "@tanstack/react-query";
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import { ChevronLeft, Folder } from "lucide-react";
|
|
3
|
+
import type * as React from "react";
|
|
3
4
|
import { useState } from "react";
|
|
4
5
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
5
6
|
import { Modal } from "@/modal/modal.tsx";
|
|
@@ -17,6 +18,20 @@ export function parentOf(entry: TreeEntry): string | null {
|
|
|
17
18
|
return entry.type === "knowledge" ? entry.node.parentId : entry.flow.parentId;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// One level of the shared tree, as a query key. The move writes into two of them and the picker
|
|
22
|
+
// reads a third, so the shape is stated once rather than spelled out at each of those places.
|
|
23
|
+
export function treeLevelKey(parentId: string | null): readonly unknown[] {
|
|
24
|
+
return ["tree", parentId];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
|
|
28
|
+
// plus on the moved row would still file into the folder it just left.
|
|
29
|
+
function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
|
|
30
|
+
return entry.type === "knowledge"
|
|
31
|
+
? { ...entry, node: { ...entry.node, parentId } }
|
|
32
|
+
: { ...entry, flow: { ...entry.flow, parentId } };
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/**
|
|
21
36
|
* ⚠️ The trap this ticket is built around: a node must not travel into its own descendants. The
|
|
22
37
|
* service refuses it (`move_cycle`, `knowledge.ts:421`), but a refusal that only arrives once the
|
|
@@ -195,3 +210,122 @@ export function MoveDialog({
|
|
|
195
210
|
</Modal>
|
|
196
211
|
);
|
|
197
212
|
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Moving a row, wherever it is asked for.
|
|
216
|
+
*
|
|
217
|
+
* ⚠️ It is asked for from two places now (#58): a drop on a folder in the tree, and the menu in the
|
|
218
|
+
* title line — the keyboard route, which since #58 is the only one the menu still has anywhere. Both
|
|
219
|
+
* are the same move, so both take it from here rather than each carrying its own mutation. Two
|
|
220
|
+
* copies of an optimistic update that rewrites two cached levels is how a rejected move ends up
|
|
221
|
+
* leaving a row standing twice.
|
|
222
|
+
*
|
|
223
|
+
* ⚠️ Optimistic, never authoritative. The row is lifted out of one level and dropped into the other
|
|
224
|
+
* before the server answers, and every refusal — 403, `parent_not_folder`, `move_cycle`,
|
|
225
|
+
* `update_conflict` — puts both levels back exactly as they were and then re-reads them.
|
|
226
|
+
*
|
|
227
|
+
* The caller renders `dialog` where it likes and words `error` itself with `moveErrorKey`: the tree
|
|
228
|
+
* says it in the sidebar, the menu over the screen, and neither position belongs to the move.
|
|
229
|
+
*/
|
|
230
|
+
export function useTreeMove({
|
|
231
|
+
onMoved,
|
|
232
|
+
}: {
|
|
233
|
+
// What the caller wants to do with the destination once the move is through — the tree opens that
|
|
234
|
+
// folder so the row is where the eye follows it. Nobody else has a tree to open.
|
|
235
|
+
onMoved?: ((destination: MoveDestination) => void) | undefined;
|
|
236
|
+
} = {}): {
|
|
237
|
+
start(entry: TreeEntry, destination: MoveDestination | null): void;
|
|
238
|
+
error: unknown;
|
|
239
|
+
dialog: React.ReactNode;
|
|
240
|
+
} {
|
|
241
|
+
const { data } = useIntelRouterContext();
|
|
242
|
+
const queryClient = useQueryClient();
|
|
243
|
+
const [moving, setMoving] = useState<{
|
|
244
|
+
entry: TreeEntry;
|
|
245
|
+
initial: MoveDestination | null;
|
|
246
|
+
} | null>(null);
|
|
247
|
+
|
|
248
|
+
const move = useMutation({
|
|
249
|
+
mutationFn: async ({
|
|
250
|
+
entry,
|
|
251
|
+
destination,
|
|
252
|
+
}: {
|
|
253
|
+
entry: TreeEntry;
|
|
254
|
+
destination: MoveDestination;
|
|
255
|
+
}) => {
|
|
256
|
+
// `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
|
|
257
|
+
// view can act on instead of an overwrite nobody notices.
|
|
258
|
+
if (entry.type === "flow") {
|
|
259
|
+
await data.updateFlow({
|
|
260
|
+
flowId: entry.id,
|
|
261
|
+
baseUpdatedAt: entry.flow.updatedAt,
|
|
262
|
+
parentId: destination.id,
|
|
263
|
+
idempotencyKey: crypto.randomUUID(),
|
|
264
|
+
});
|
|
265
|
+
} else {
|
|
266
|
+
await data.updateKnowledge({
|
|
267
|
+
nodeId: entry.id,
|
|
268
|
+
baseUpdatedAt: entry.node.updatedAt,
|
|
269
|
+
parentId: destination.id,
|
|
270
|
+
idempotencyKey: crypto.randomUUID(),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
onMutate: async ({ entry, destination }) => {
|
|
275
|
+
const fromKey = treeLevelKey(parentOf(entry));
|
|
276
|
+
const toKey = treeLevelKey(destination.id);
|
|
277
|
+
await Promise.all([
|
|
278
|
+
queryClient.cancelQueries({ queryKey: fromKey }),
|
|
279
|
+
queryClient.cancelQueries({ queryKey: toKey }),
|
|
280
|
+
]);
|
|
281
|
+
const snapshot = [
|
|
282
|
+
[fromKey, queryClient.getQueryData<TreeEntry[]>(fromKey)],
|
|
283
|
+
[toKey, queryClient.getQueryData<TreeEntry[]>(toKey)],
|
|
284
|
+
] as const;
|
|
285
|
+
queryClient.setQueryData<TreeEntry[]>(fromKey, (current) =>
|
|
286
|
+
current?.filter((row) => row.id !== entry.id),
|
|
287
|
+
);
|
|
288
|
+
// A level nobody has opened stays unloaded: writing one here would show a folder's contents
|
|
289
|
+
// that were never read.
|
|
290
|
+
queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
|
|
291
|
+
current === undefined
|
|
292
|
+
? current
|
|
293
|
+
: [...current.filter((row) => row.id !== entry.id), withParent(entry, destination.id)],
|
|
294
|
+
);
|
|
295
|
+
return { snapshot };
|
|
296
|
+
},
|
|
297
|
+
onError: (_error, _variables, context) => {
|
|
298
|
+
for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
|
|
299
|
+
},
|
|
300
|
+
onSuccess: (_result, { destination }) => onMoved?.(destination),
|
|
301
|
+
onSettled: async (_result, _error, { entry, destination }) => {
|
|
302
|
+
await Promise.all([
|
|
303
|
+
queryClient.invalidateQueries({ queryKey: treeLevelKey(parentOf(entry)) }),
|
|
304
|
+
queryClient.invalidateQueries({ queryKey: treeLevelKey(destination.id) }),
|
|
305
|
+
queryClient.invalidateQueries({
|
|
306
|
+
queryKey: [entry.type === "flow" ? "flows" : "knowledge-graph"],
|
|
307
|
+
}),
|
|
308
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
309
|
+
]);
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
start(entry, destination) {
|
|
315
|
+
move.reset();
|
|
316
|
+
setMoving({ entry, initial: destination });
|
|
317
|
+
},
|
|
318
|
+
error: move.isError ? move.error : null,
|
|
319
|
+
dialog: moving ? (
|
|
320
|
+
<MoveDialog
|
|
321
|
+
entry={moving.entry}
|
|
322
|
+
initial={moving.initial}
|
|
323
|
+
close={() => setMoving(null)}
|
|
324
|
+
submit={(destination) => {
|
|
325
|
+
setMoving(null);
|
|
326
|
+
move.mutate({ entry: moving.entry, destination });
|
|
327
|
+
}}
|
|
328
|
+
/>
|
|
329
|
+
) : null,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -84,10 +84,20 @@ export function flowEntry(flow: Flow): TreeEntry {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
export function createIntelDataProvider(
|
|
87
|
-
deps: {
|
|
87
|
+
deps: {
|
|
88
|
+
fetch?: typeof fetch;
|
|
89
|
+
baseUrl?: string;
|
|
90
|
+
onUnauthorized?(): void;
|
|
91
|
+
navigate?(url: string): void;
|
|
92
|
+
} = {},
|
|
88
93
|
): IntelDataProvider {
|
|
89
94
|
const doFetch = deps.fetch ?? fetch;
|
|
90
95
|
const baseUrl = deps.baseUrl?.replace(/\/$/, "") ?? "";
|
|
96
|
+
const navigate =
|
|
97
|
+
deps.navigate ??
|
|
98
|
+
((url: string) => {
|
|
99
|
+
if (typeof window !== "undefined") window.location.assign(url);
|
|
100
|
+
});
|
|
91
101
|
const unauthorized =
|
|
92
102
|
deps.onUnauthorized ??
|
|
93
103
|
(() => {
|
|
@@ -400,8 +410,10 @@ export function createIntelDataProvider(
|
|
|
400
410
|
return await request("/tools", ToolCatalog);
|
|
401
411
|
},
|
|
402
412
|
|
|
403
|
-
|
|
404
|
-
|
|
413
|
+
// The Worker serves the connect route, so this navigation belongs to the data layer for the
|
|
414
|
+
// same reason `loginPath` does. `silent=1` is what makes it a redirect nobody has to watch.
|
|
415
|
+
startPortalSignIn(returnTo = "/tools") {
|
|
416
|
+
navigate(`${baseUrl}/auth/connect?returnTo=${encodeURIComponent(returnTo)}&silent=1`);
|
|
405
417
|
},
|
|
406
418
|
async logout() {
|
|
407
419
|
const response = await doFetch(`${baseUrl}/auth/logout`, {
|
|
@@ -77,7 +77,10 @@ export interface IntelDataProvider {
|
|
|
77
77
|
appendKnowledgeTableRows(
|
|
78
78
|
input: AppendKnowledgeTableRowsInput,
|
|
79
79
|
): Promise<AppendKnowledgeTableRowsResult>;
|
|
80
|
-
|
|
80
|
+
// Walks the browser through the portal's OAuth flow without asking anybody anything: Gate is the
|
|
81
|
+
// identity provider Cloudflare Access consumes, so a signed-in person is already known there
|
|
82
|
+
// (#60). It navigates away — the caller renders no button for it and gets no answer back.
|
|
83
|
+
startPortalSignIn(returnTo?: string): void;
|
|
81
84
|
listKnowledgeVersions(nodeId: string): Promise<KnowledgeVersionList>;
|
|
82
85
|
updateKnowledge(input: UpdateKnowledgeNodeInput): Promise<KnowledgeNode>;
|
|
83
86
|
archiveKnowledge(input: ArchiveKnowledgeNodeInput): Promise<KnowledgeNode>;
|
package/src/flows/flows.tsx
CHANGED
|
@@ -28,11 +28,11 @@ import { nodeIcon } from "@/flows/node-icon/node-icon.ts";
|
|
|
28
28
|
import { NodePalette, usePaletteOpen } from "@/flows/node-palette/node-palette.tsx";
|
|
29
29
|
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
30
30
|
import { Modal } from "@/modal/modal.tsx";
|
|
31
|
-
import { ResourceMenu } from "@/resource-menu/resource-menu.tsx";
|
|
32
31
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
33
32
|
import { selectedFrom, viewFrom } from "@/router/selection-search.ts";
|
|
34
33
|
import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
|
|
35
34
|
import { useSystemTheme } from "@/theme/theme.ts";
|
|
35
|
+
import { TitleRow } from "@/title-row/title-row.tsx";
|
|
36
36
|
|
|
37
37
|
type CanvasNode = Node<{ node: FlowNode }, "intel">;
|
|
38
38
|
type CanvasEdge = Edge;
|
|
@@ -664,49 +664,44 @@ function FlowTitle({
|
|
|
664
664
|
!publishable ? "flows.publishNothing" : unpublished ? "flows.publishNeeded" : "flows.publish",
|
|
665
665
|
);
|
|
666
666
|
return (
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
<TooltipTrigger asChild>
|
|
678
|
-
<span className="inline-flex">
|
|
679
|
-
{/* Publishing goes through the preview, always. ADR-0004 §5 asks the author to see
|
|
667
|
+
// ⚠️ The menu stands to the RIGHT of the run button, not before it (#53, deliberate). Run is the
|
|
668
|
+
// loudest thing in this line and used to hold the edge; the menu takes it, because a menu one can
|
|
669
|
+
// hit without looking is worth more than the primary button being outermost. `TitleRow` is what
|
|
670
|
+
// enforces that — nothing passed in here can get past the menu.
|
|
671
|
+
<TitleRow title={flow.title} description={flow.description} target={{ type: "flow", flow }}>
|
|
672
|
+
<TooltipProvider delayDuration={300}>
|
|
673
|
+
<Tooltip>
|
|
674
|
+
<TooltipTrigger asChild>
|
|
675
|
+
<span className="inline-flex">
|
|
676
|
+
{/* Publishing goes through the preview, always. ADR-0004 §5 asks the author to see
|
|
680
677
|
the freeze before it happens, and a second, quieter path around the dialog would
|
|
681
678
|
be the one everybody ends up using. */}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
</div>
|
|
709
|
-
</div>
|
|
679
|
+
<button
|
|
680
|
+
type="button"
|
|
681
|
+
onClick={onPublish}
|
|
682
|
+
disabled={!publishable}
|
|
683
|
+
aria-label={publishLabel}
|
|
684
|
+
className={`inline-flex size-8 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 ${
|
|
685
|
+
publishable && unpublished ? "border-primary text-primary" : ""
|
|
686
|
+
}`}
|
|
687
|
+
>
|
|
688
|
+
<Send aria-hidden="true" className="size-4" />
|
|
689
|
+
</button>
|
|
690
|
+
</span>
|
|
691
|
+
</TooltipTrigger>
|
|
692
|
+
<TooltipContent>{publishLabel}</TooltipContent>
|
|
693
|
+
</Tooltip>
|
|
694
|
+
</TooltipProvider>
|
|
695
|
+
<SaveButton dirty={dirty && canMutate} saving={saving} onSave={onSave} />
|
|
696
|
+
<button
|
|
697
|
+
type="button"
|
|
698
|
+
onClick={onRun}
|
|
699
|
+
disabled={!canMutate || unpublished || running}
|
|
700
|
+
className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
701
|
+
>
|
|
702
|
+
{i18n.t("flows.run")}
|
|
703
|
+
</button>
|
|
704
|
+
</TitleRow>
|
|
710
705
|
);
|
|
711
706
|
}
|
|
712
707
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Plus, X } from "lucide-react";
|
|
2
2
|
import { useId, useRef, useState } from "react";
|
|
3
3
|
import { type NodeIcon, nodeIcon } from "@/flows/node-icon/node-icon.ts";
|
|
4
|
+
import { cn } from "@/lib/utils.ts";
|
|
4
5
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
5
6
|
import type { NodePaletteProps, PaletteKind } from "./node-palette.types.ts";
|
|
6
7
|
|
|
@@ -111,11 +112,29 @@ export function NodePalette<K extends PaletteKind>({
|
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
115
|
+
// Open, the trigger is the first segment of the bar itself rather than a second box above it: one
|
|
116
|
+
// closes a bar where it is. Two details carry that and neither is decoration:
|
|
117
|
+
//
|
|
118
|
+
// ⚠️ The negative margin is the frame's own padding plus its border. Opening hands the frame from
|
|
119
|
+
// the trigger to the row around it, and a frame drawn inside would push the trigger down and right
|
|
120
|
+
// by exactly that sum. The margin lets the row grow outwards instead, so the 36px one clicks stays
|
|
121
|
+
// on the same pixel open and collapsed. Change `p-1` and this has to change with it.
|
|
122
|
+
//
|
|
123
|
+
// ⚠️ The toolbar stays an element of its own inside the row: it is what `aria-controls` names and
|
|
124
|
+
// what the arrow keys walk, and the trigger must not become an eighth entry in that ring.
|
|
125
|
+
//
|
|
126
|
+
// The `w-max` that used to stand here went with the structure it guarded. While the bar was a
|
|
127
|
+
// block below the trigger, the shrink-wrapped box took the collapsed 36px as its maximum and
|
|
128
|
+
// folded the entries into a column; one flex row is sized from its content in either state, which
|
|
129
|
+
// was measured at 1280px and at 420px with the class and without it (#55).
|
|
117
130
|
return (
|
|
118
|
-
<div
|
|
131
|
+
<div
|
|
132
|
+
className={cn(
|
|
133
|
+
"absolute left-4 top-4 z-10 max-w-[calc(100%-2rem)]",
|
|
134
|
+
open &&
|
|
135
|
+
"-m-[calc(0.25rem+1px)] flex flex-wrap items-center gap-1 rounded-lg border bg-card/95 p-1 shadow-sm backdrop-blur",
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
119
138
|
<button
|
|
120
139
|
ref={triggerRef}
|
|
121
140
|
type="button"
|
|
@@ -123,7 +142,10 @@ export function NodePalette<K extends PaletteKind>({
|
|
|
123
142
|
aria-controls={listId}
|
|
124
143
|
onClick={() => (open ? close() : setOpen(true))}
|
|
125
144
|
onKeyDown={onTriggerKeyDown}
|
|
126
|
-
className=
|
|
145
|
+
className={cn(
|
|
146
|
+
"inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-card-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
|
|
147
|
+
!open && "border bg-card/95 shadow-sm backdrop-blur",
|
|
148
|
+
)}
|
|
127
149
|
>
|
|
128
150
|
{open ? (
|
|
129
151
|
<X aria-hidden="true" className="size-4" />
|
|
@@ -143,7 +165,9 @@ export function NodePalette<K extends PaletteKind>({
|
|
|
143
165
|
aria-orientation="horizontal"
|
|
144
166
|
aria-label={i18n.t("flows.nodePalette")}
|
|
145
167
|
onKeyDown={onListKeyDown}
|
|
146
|
-
|
|
168
|
+
// `min-w-0` so a narrow bar makes the entries wrap among themselves instead of forcing the
|
|
169
|
+
// whole row wider than the canvas allows.
|
|
170
|
+
className="flex min-w-0 max-w-full flex-wrap gap-1"
|
|
147
171
|
>
|
|
148
172
|
{kinds.map((kind, index) => {
|
|
149
173
|
const Icon: NodeIcon = nodeIcon[kind];
|
|
@@ -160,7 +184,9 @@ export function NodePalette<K extends PaletteKind>({
|
|
|
160
184
|
setActive(index);
|
|
161
185
|
add(kind);
|
|
162
186
|
}}
|
|
163
|
-
|
|
187
|
+
// `h-9` is the trigger's height: sharing one row only reads as one row if the entries
|
|
188
|
+
// start on the trigger's top edge instead of floating in the middle of it.
|
|
189
|
+
className="inline-flex h-9 items-center gap-1.5 rounded-md px-2 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
|
164
190
|
>
|
|
165
191
|
<Icon aria-hidden="true" className="size-3.5" />
|
|
166
192
|
{i18n.t(`flows.node.${kind}`)}
|
package/src/i18n/en.json
CHANGED
|
@@ -138,10 +138,9 @@
|
|
|
138
138
|
"knowledge.link.unresolved": "Unavailable document",
|
|
139
139
|
"knowledge.saveConflict": "This document changed elsewhere. Reload it before saving again.",
|
|
140
140
|
"knowledge.saveError": "This document could not be saved. Reload and try again.",
|
|
141
|
-
"tools.
|
|
142
|
-
"tools.
|
|
143
|
-
"tools.
|
|
144
|
-
"tools.disconnectedHelp": "Sign in to the company MCP portal once. The portal decides which servers you may use and holds their credentials; Intel never sees them.",
|
|
141
|
+
"tools.signingIn": "Signing you in to the company portal…",
|
|
142
|
+
"tools.noAccess": "No access to the company portal",
|
|
143
|
+
"tools.noAccessHelp": "Your Intel account does not reach the company MCP portal, so there is nothing to show here. An administrator decides in the portal who may use which server; ask them to include you.",
|
|
145
144
|
"tools.empty": "No tools available to you",
|
|
146
145
|
"tools.emptyHelp": "The portal answered, and it offers your account no tools. An administrator decides in the portal which servers exist and who may reach them.",
|
|
147
146
|
"tools.unreachable": "Portal not reachable",
|
|
@@ -152,7 +151,6 @@
|
|
|
152
151
|
"tools.outputSchema": "Result schema",
|
|
153
152
|
"tools.destructiveShort": "Destructive",
|
|
154
153
|
"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.",
|
|
155
|
-
"tools.connectFailed": "The portal could not be connected with your current access.",
|
|
156
154
|
"tools.arguments": "Arguments",
|
|
157
155
|
"tools.invalidJson": "The arguments must be a JSON object in curly braces; a list or a single value will not work.",
|
|
158
156
|
"flows.select": "Select a flow or create one to open the visual editor.",
|
|
@@ -8,9 +8,9 @@ import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
|
8
8
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
9
9
|
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
10
10
|
import { KnowledgeTablePanel } from "@/knowledge-table/knowledge-table.tsx";
|
|
11
|
-
import { ResourceMenu } from "@/resource-menu/resource-menu.tsx";
|
|
12
11
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
13
12
|
import { graphViewFrom, selectedFrom } from "@/router/selection-search.ts";
|
|
13
|
+
import { TitleRow } from "@/title-row/title-row.tsx";
|
|
14
14
|
|
|
15
15
|
const KnowledgeEditor = lazy(async () => ({
|
|
16
16
|
default: (await import("@/knowledge-editor/knowledge-editor.tsx")).KnowledgeEditor,
|
|
@@ -57,7 +57,11 @@ export function Knowledge() {
|
|
|
57
57
|
</ActionSlot>
|
|
58
58
|
) : null}
|
|
59
59
|
<section className="relative flex min-h-0 min-w-0 flex-1 flex-col bg-card">
|
|
60
|
-
{
|
|
60
|
+
{/* ⚠️ Only the root draws its graph on its own. A folder's graph hangs *under* the folder's
|
|
61
|
+
title line rather than in place of it (#58): the line is the one place its menu lives,
|
|
62
|
+
and a folder that could only be renamed while the graph happened to be switched off
|
|
63
|
+
would be a folder one cannot rename. */}
|
|
64
|
+
{selectedId === null && selection.graph ? (
|
|
61
65
|
<GraphPane
|
|
62
66
|
query={relations}
|
|
63
67
|
select={(node) =>
|
|
@@ -89,42 +93,48 @@ export function Knowledge() {
|
|
|
89
93
|
) : (
|
|
90
94
|
<>
|
|
91
95
|
{/* The document's own line: its name, and beside it only what acts on this one
|
|
92
|
-
document. What the four loose icons used to do is now one menu
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
document. What the four loose icons used to do is now one menu (#24) — and since #58
|
|
97
|
+
this line is the only place that menu appears at all, for every kind including a
|
|
98
|
+
folder, whose whole screen is this line.
|
|
99
|
+
⚠️ The order of the right-hand group is `TitleRow`'s (#53), not this screen's: the
|
|
100
|
+
version button is handed over as a child and lands before the menu whatever else
|
|
101
|
+
shows up later.
|
|
96
102
|
⚠️ The view switch is deliberately NOT here: it changes how the current area is
|
|
97
103
|
shown, not the document, and it stays beside the breadcrumb where the area is
|
|
98
104
|
named. */}
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
105
|
+
<TitleRow
|
|
106
|
+
title={selected.title}
|
|
107
|
+
description={selected.description}
|
|
108
|
+
target={{ type: "knowledge", node: selected }}
|
|
109
|
+
>
|
|
110
|
+
{selected.kind !== "folder" && (
|
|
111
|
+
<TooltipProvider delayDuration={300}>
|
|
112
|
+
<Tooltip>
|
|
113
|
+
<TooltipTrigger
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={() => setVersionsOpen((value) => !value)}
|
|
116
|
+
aria-label={i18n.t("knowledge.versions")}
|
|
117
|
+
aria-expanded={versionsOpen}
|
|
118
|
+
className="inline-flex size-8 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
119
|
+
>
|
|
120
|
+
<History aria-hidden="true" className="size-4" />
|
|
121
|
+
</TooltipTrigger>
|
|
122
|
+
<TooltipContent>{i18n.t("knowledge.versions")}</TooltipContent>
|
|
123
|
+
</Tooltip>
|
|
124
|
+
</TooltipProvider>
|
|
125
|
+
)}
|
|
126
|
+
</TitleRow>
|
|
127
|
+
{graphable && selection.graph ? (
|
|
128
|
+
<GraphPane
|
|
129
|
+
query={relations}
|
|
130
|
+
select={(node) =>
|
|
131
|
+
void navigate({
|
|
132
|
+
to: node.kind === "flow" ? "/flows" : "/knowledge",
|
|
133
|
+
search: { select: node.id },
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
/>
|
|
137
|
+
) : selected.kind === "folder" ? (
|
|
128
138
|
<div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
|
|
129
139
|
{i18n.t("knowledge.folderHelp")}
|
|
130
140
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { KnowledgeNode } from "@anchrd/intel-contract";
|
|
2
2
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { Download } from "lucide-react";
|
|
4
|
+
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
4
5
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -43,25 +44,36 @@ export function KnowledgeTablePanel({ node }: { node: KnowledgeNode }) {
|
|
|
43
44
|
|
|
44
45
|
return (
|
|
45
46
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
{/* ⚠️ Both of these used to be a second bar of their own, directly under the title line (#54):
|
|
48
|
+
two headers stacked, and the table starting a whole row lower for it. A table is one
|
|
49
|
+
Knowledge kind among four, not a screen with its own chrome — so the count goes beside the
|
|
50
|
+
title as a quiet word and the export joins the buttons in the title line. They are rendered
|
|
51
|
+
from here rather than from the screen because this is where the query lives; the screen
|
|
52
|
+
would otherwise have to load a table it does not show. */}
|
|
53
|
+
<ActionSlot name="title-meta">
|
|
54
|
+
{table.data
|
|
55
|
+
? i18n.t("knowledge.table.summary", {
|
|
56
|
+
rows: table.data.rows.length,
|
|
57
|
+
columns: table.data.columns.length,
|
|
58
|
+
})
|
|
59
|
+
: ""}
|
|
60
|
+
</ActionSlot>
|
|
61
|
+
<ActionSlot name="title-actions">
|
|
55
62
|
<button
|
|
56
63
|
type="button"
|
|
57
64
|
onClick={() => download.mutate()}
|
|
65
|
+
// Unchanged: nothing to export before the header is known, and a button that answers with
|
|
66
|
+
// an empty file is worse than one that says it is not ready.
|
|
58
67
|
disabled={download.isPending || !table.data?.columns.length}
|
|
59
|
-
className="inline-flex items-center gap-2 rounded-md border px-3
|
|
68
|
+
className="inline-flex h-8 items-center gap-2 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
60
69
|
>
|
|
61
70
|
<Download aria-hidden="true" className="size-4" />
|
|
62
71
|
{i18n.t("knowledge.table.download")}
|
|
63
72
|
</button>
|
|
64
|
-
</
|
|
73
|
+
</ActionSlot>
|
|
74
|
+
{/* ⚠️ The refusal stays down here, in the body, where a sentence has room to be read. The
|
|
75
|
+
title line has none — a failed export that only greyed a button in a header would be a
|
|
76
|
+
failure nobody is told about. */}
|
|
65
77
|
{download.isError ? (
|
|
66
78
|
<p role="alert" className="mx-6 mt-4 text-sm text-destructive">
|
|
67
79
|
{i18n.t("knowledge.downloadFailed")}
|
|
@@ -8,7 +8,9 @@ import type {
|
|
|
8
8
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
9
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
10
10
|
import { Archive, CornerLeftUp, Ellipsis, Link2, Pencil, Share2, Trash2 } from "lucide-react";
|
|
11
|
+
import type * as React from "react";
|
|
11
12
|
import { useState } from "react";
|
|
13
|
+
import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
|
|
12
14
|
import {
|
|
13
15
|
DropdownMenu,
|
|
14
16
|
DropdownMenuContent,
|
|
@@ -21,6 +23,8 @@ import {
|
|
|
21
23
|
DropdownMenuSubTrigger,
|
|
22
24
|
DropdownMenuTrigger,
|
|
23
25
|
} from "@/components/ui/dropdown-menu";
|
|
26
|
+
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
27
|
+
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
24
28
|
import { Modal } from "@/modal/modal.tsx";
|
|
25
29
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
26
30
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
@@ -28,9 +32,11 @@ import { selectedFrom } from "@/router/selection-search.ts";
|
|
|
28
32
|
/**
|
|
29
33
|
* What a resource's own actions are, at the place the resource stands (#24).
|
|
30
34
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
35
|
+
* ⚠️ Since #58 there is exactly one such place: the title line of the open document, table,
|
|
36
|
+
* attachment, folder or flow. The tree row used to carry the same menu, which put ten buttons into a
|
|
37
|
+
* 240px column and made the sidebar something to read buttons in rather than titles. Everything the
|
|
38
|
+
* row menu could do is reached here — including moving, which is why this component owns the folder
|
|
39
|
+
* picker rather than being handed one.
|
|
34
40
|
*
|
|
35
41
|
* ⚠️ An entry that does not apply is absent, never disabled: a folder has no retrieval mode, a flow
|
|
36
42
|
* is shared through the folder it is filed in rather than on its own (ADR-0004 §2). A greyed-out row
|
|
@@ -40,6 +46,21 @@ export type ResourceTarget =
|
|
|
40
46
|
| { type: "knowledge"; node: KnowledgeNode }
|
|
41
47
|
| { type: "flow"; flow: Flow };
|
|
42
48
|
|
|
49
|
+
// The same thing as a tree row, because that is what a move works on: a row with a place in the
|
|
50
|
+
// shared tree. The two shapes are the same record seen from two screens, so this is a re-labelling
|
|
51
|
+
// and never a second source of truth.
|
|
52
|
+
function entryOf(target: ResourceTarget): TreeEntry {
|
|
53
|
+
return target.type === "flow"
|
|
54
|
+
? flowEntry(target.flow)
|
|
55
|
+
: {
|
|
56
|
+
type: "knowledge",
|
|
57
|
+
id: target.node.id,
|
|
58
|
+
title: target.node.title,
|
|
59
|
+
kind: target.node.kind,
|
|
60
|
+
node: target.node,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
function idOf(target: ResourceTarget): string {
|
|
44
65
|
return target.type === "knowledge" ? target.node.id : target.flow.id;
|
|
45
66
|
}
|
|
@@ -77,19 +98,18 @@ const contextPolicies: readonly ContextPolicy[] = ["pinned", "relevant", "explic
|
|
|
77
98
|
/**
|
|
78
99
|
* The three-dot menu.
|
|
79
100
|
*
|
|
80
|
-
* `variant` is only how the trigger is painted.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
101
|
+
* `variant` is only how the trigger is painted. `title` is the one in use: a plain icon button at
|
|
102
|
+
* the end of a title line, put there by `TitleRow` and by nobody else, so it is always the last
|
|
103
|
+
* thing in that line (#53). `row` is the sidebar form — hidden until the row is hovered or something
|
|
104
|
+
* in it takes focus — and is unused since #58; it is kept because the trigger's two paintings are
|
|
105
|
+
* the only difference between the two places, and re-deriving it would be the harder half.
|
|
84
106
|
*/
|
|
85
107
|
export function ResourceMenu({
|
|
86
108
|
target,
|
|
87
109
|
variant,
|
|
88
|
-
onMove,
|
|
89
110
|
}: {
|
|
90
111
|
target: ResourceTarget;
|
|
91
112
|
variant: "row" | "title";
|
|
92
|
-
onMove?: (() => void) | undefined;
|
|
93
113
|
}) {
|
|
94
114
|
const { data, i18n } = useIntelRouterContext();
|
|
95
115
|
const queryClient = useQueryClient();
|
|
@@ -98,6 +118,10 @@ export function ResourceMenu({
|
|
|
98
118
|
const [sharing, setSharing] = useState(false);
|
|
99
119
|
const [linksOpen, setLinksOpen] = useState(false);
|
|
100
120
|
const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
|
|
121
|
+
// ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
|
|
122
|
+
// the name. #27 gave moving a second, equal route through a folder picker, and that route lived in
|
|
123
|
+
// the tree row's menu. With the row menu gone (#58) it lives here, or it does not exist.
|
|
124
|
+
const move = useTreeMove();
|
|
101
125
|
|
|
102
126
|
const id = idOf(target);
|
|
103
127
|
const title = titleOf(target);
|
|
@@ -193,6 +217,7 @@ export function ResourceMenu({
|
|
|
193
217
|
<>
|
|
194
218
|
<DropdownMenu>
|
|
195
219
|
<DropdownMenuTrigger
|
|
220
|
+
data-resource-menu=""
|
|
196
221
|
aria-label={i18n.t("resource.menu", { title })}
|
|
197
222
|
className={
|
|
198
223
|
variant === "row"
|
|
@@ -207,12 +232,10 @@ export function ResourceMenu({
|
|
|
207
232
|
<Pencil aria-hidden="true" />
|
|
208
233
|
{i18n.t("resource.rename")}
|
|
209
234
|
</DropdownMenuItem>
|
|
210
|
-
{
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
</DropdownMenuItem>
|
|
215
|
-
) : null}
|
|
235
|
+
<DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
|
|
236
|
+
<CornerLeftUp aria-hidden="true" />
|
|
237
|
+
{i18n.t("tree.move.action")}
|
|
238
|
+
</DropdownMenuItem>
|
|
216
239
|
{retrievable || linkable || node ? <DropdownMenuSeparator /> : null}
|
|
217
240
|
{/* ⚠️ A submenu with a checked value, not an embedded `select`. A form control inside a
|
|
218
241
|
menu takes the keyboard away from the menu that contains it, and the current value is
|
|
@@ -264,15 +287,15 @@ export function ResourceMenu({
|
|
|
264
287
|
</DropdownMenuContent>
|
|
265
288
|
</DropdownMenu>
|
|
266
289
|
{/* The menu closes on selection, so a refusal has nowhere to live inside it. It stands over
|
|
267
|
-
the screen instead, where it is read
|
|
290
|
+
the screen instead, where it is read whatever the title line asked for. */}
|
|
268
291
|
{failure !== undefined && failure !== null ? (
|
|
269
|
-
<
|
|
270
|
-
role="alert"
|
|
271
|
-
className="fixed inset-x-0 bottom-5 z-50 mx-auto w-fit max-w-md rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
|
|
272
|
-
>
|
|
273
|
-
{i18n.t(resourceErrorKey(failure))}
|
|
274
|
-
</p>
|
|
292
|
+
<MenuFailure>{i18n.t(resourceErrorKey(failure))}</MenuFailure>
|
|
275
293
|
) : null}
|
|
294
|
+
{/* ⚠️ A move has four refusals of its own and they are told apart by `moveErrorKey`, not by
|
|
295
|
+
`resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
|
|
296
|
+
among the changes above, and one shared sentence would leave the reader guessing. */}
|
|
297
|
+
{move.error ? <MenuFailure>{i18n.t(moveErrorKey(move.error))}</MenuFailure> : null}
|
|
298
|
+
{move.dialog}
|
|
276
299
|
{renaming ? (
|
|
277
300
|
<RenameDialog
|
|
278
301
|
title={title}
|
|
@@ -291,6 +314,18 @@ export function ResourceMenu({
|
|
|
291
314
|
);
|
|
292
315
|
}
|
|
293
316
|
|
|
317
|
+
// One refusal, over the screen rather than in the menu that is already gone by the time it arrives.
|
|
318
|
+
function MenuFailure({ children }: { children: React.ReactNode }) {
|
|
319
|
+
return (
|
|
320
|
+
<p
|
|
321
|
+
role="alert"
|
|
322
|
+
className="fixed inset-x-0 bottom-5 z-50 mx-auto w-fit max-w-md rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
|
|
323
|
+
>
|
|
324
|
+
{children}
|
|
325
|
+
</p>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
294
329
|
function RenameDialog({
|
|
295
330
|
title,
|
|
296
331
|
pending,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one line an open thing gets: its name on the left, everything that acts on it on the right
|
|
6
|
+
* (#53).
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ The order of the right-hand group belongs to this component, not to its callers. Whatever they
|
|
9
|
+
* pass as `children` is rendered before the menu and the menu is appended last, always — so a screen
|
|
10
|
+
* that grows a new button cannot push the three dots to the left, and there is no second place where
|
|
11
|
+
* the rule could be forgotten. That is the whole point: the menu holds rename, move, share and
|
|
12
|
+
* archive, and a position one has to look for is a position one stops using.
|
|
13
|
+
*
|
|
14
|
+
* The two slots are for what belongs to this thing but lives further down the tree, where its state
|
|
15
|
+
* is. `title-meta` is a quiet word beside the name (a table's row count); `title-actions` is a
|
|
16
|
+
* button in the group (saving a document, exporting a table). Both are filled through `ActionSlot`
|
|
17
|
+
* and both sit before the menu for the same reason `children` do.
|
|
18
|
+
*/
|
|
19
|
+
export function TitleRow({
|
|
20
|
+
title,
|
|
21
|
+
description,
|
|
22
|
+
target,
|
|
23
|
+
children,
|
|
24
|
+
}: {
|
|
25
|
+
title: string;
|
|
26
|
+
description?: string | null;
|
|
27
|
+
target: ResourceTarget;
|
|
28
|
+
children?: React.ReactNode;
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex items-start justify-between gap-5 border-b px-6 py-4">
|
|
32
|
+
<div className="min-w-0">
|
|
33
|
+
<div className="flex min-w-0 items-baseline gap-3">
|
|
34
|
+
<h2 className="truncate text-lg font-semibold">{title}</h2>
|
|
35
|
+
<span data-slot="title-meta" className="shrink-0 text-sm text-muted-foreground" />
|
|
36
|
+
</div>
|
|
37
|
+
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
|
|
38
|
+
</div>
|
|
39
|
+
{/* ⚠️ Document order is tab order here — nothing carries a `tabIndex`. The menu is therefore
|
|
40
|
+
reached last by the keyboard for the same reason it stands last on screen, and the two
|
|
41
|
+
cannot drift apart without someone rewriting this line. */}
|
|
42
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
43
|
+
<div data-slot="title-actions" className="flex items-center gap-2" />
|
|
44
|
+
{children}
|
|
45
|
+
<ResourceMenu target={target} variant="title" />
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
package/src/tools/tools.tsx
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { ToolCapability } from "@anchrd/intel-contract";
|
|
2
2
|
import { useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { useRouterState } from "@tanstack/react-router";
|
|
4
|
-
import { AlertTriangle, ChevronRight,
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import { Button, buttonVariants } from "@/components/ui/button";
|
|
4
|
+
import { AlertTriangle, ChevronRight, PlugZap, ShieldOff, Wrench } from "lucide-react";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
8
7
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
9
8
|
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
10
9
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -20,6 +19,23 @@ function toolOrigin(name: string): string | null {
|
|
|
20
19
|
return boundary > 0 ? name.slice(0, boundary) : null;
|
|
21
20
|
}
|
|
22
21
|
|
|
22
|
+
// Signed in to Intel is signed in to the portal, so the sign-in is attempted at most once per
|
|
23
|
+
// visit. A second attempt after a refusal would be a redirect loop with an unchanging answer, and
|
|
24
|
+
// the marker in the URL only survives until the next navigation — the browser session remembers it
|
|
25
|
+
// instead (#60).
|
|
26
|
+
const SignInAttemptKey = "intel.portal-sign-in-attempted";
|
|
27
|
+
|
|
28
|
+
// Reading `sessionStorage` throws outright in a few privacy modes, so the guard is a try, not a
|
|
29
|
+
// feature check. Losing the note costs one extra redirect, and the marker the refusal leaves in the
|
|
30
|
+
// URL still ends the walk — it must never cost the screen.
|
|
31
|
+
function attemptStore(): Storage | null {
|
|
32
|
+
try {
|
|
33
|
+
return typeof window === "undefined" ? null : window.sessionStorage;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
// The catalog is one live `tools/list` with the signed-in user's own portal token. Nothing here is
|
|
24
40
|
// stored, mirrored or administered — the screen shows what the portal answers and nothing else.
|
|
25
41
|
export function Tools() {
|
|
@@ -28,36 +44,34 @@ export function Tools() {
|
|
|
28
44
|
// A hit from the header search arrives as `?select=<tool name>`, and the row it names opens with
|
|
29
45
|
// the list. Nothing else about the row changes: it is still only a disclosure.
|
|
30
46
|
const requested = useRouterState({ select: (state) => selectedFrom(state.location.search) });
|
|
31
|
-
// The
|
|
32
|
-
// the value
|
|
33
|
-
const
|
|
47
|
+
// The silent sign-in returns here with a marker when the portal refused. Only its presence is
|
|
48
|
+
// used: the value comes from outside and could carry provider detail, so it is never rendered.
|
|
49
|
+
const refused =
|
|
34
50
|
typeof window !== "undefined" &&
|
|
35
51
|
new URLSearchParams(window.location.search).has("connectError");
|
|
52
|
+
const [signingIn, setSigningIn] = React.useState(false);
|
|
53
|
+
|
|
54
|
+
// ⚠️ Nobody is asked to start this. The portal sign-in is a browser redirect, so the screen
|
|
55
|
+
// begins it itself the moment the catalog says there is no portal session yet — that is the whole
|
|
56
|
+
// of what replaced the "Connect the portal" button.
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
if (!catalog.data) return;
|
|
59
|
+
const attempts = attemptStore();
|
|
60
|
+
if (catalog.data.portalConnected) {
|
|
61
|
+
// An answered sign-in frees the next one: when the token later expires beyond renewal, the
|
|
62
|
+
// same silent walk runs again rather than stopping at a screen.
|
|
63
|
+
attempts?.removeItem(SignInAttemptKey);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (refused || attempts?.getItem(SignInAttemptKey)) return;
|
|
67
|
+
attempts?.setItem(SignInAttemptKey, "1");
|
|
68
|
+
setSigningIn(true);
|
|
69
|
+
data.startPortalSignIn("/tools");
|
|
70
|
+
}, [catalog.data, data, refused]);
|
|
36
71
|
|
|
37
72
|
return (
|
|
38
73
|
<div className="min-h-full p-8">
|
|
39
|
-
{catalog.data?.portalConnected ? (
|
|
40
|
-
<ActionSlot>
|
|
41
|
-
<a
|
|
42
|
-
href={data.portalConnectUrl("/tools")}
|
|
43
|
-
className={buttonVariants({ variant: "outline", size: "sm" })}
|
|
44
|
-
>
|
|
45
|
-
<LogIn aria-hidden="true" />
|
|
46
|
-
{i18n.t("tools.reconnect")}
|
|
47
|
-
</a>
|
|
48
|
-
</ActionSlot>
|
|
49
|
-
) : null}
|
|
50
|
-
|
|
51
74
|
<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>
|
|
59
|
-
)}
|
|
60
|
-
|
|
61
75
|
{catalog.isPending ? (
|
|
62
76
|
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
63
77
|
) : catalog.isError ? (
|
|
@@ -74,16 +88,21 @@ export function Tools() {
|
|
|
74
88
|
</Button>
|
|
75
89
|
</Notice>
|
|
76
90
|
) : !catalog.data.portalConnected ? (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
signingIn ? (
|
|
92
|
+
<p className="text-sm text-muted-foreground">{i18n.t("tools.signingIn")}</p>
|
|
93
|
+
) : (
|
|
94
|
+
// The end of the silent walk for somebody no Access policy carries. It is a sentence
|
|
95
|
+
// about access, not an invitation to connect: there is nothing they could click that
|
|
96
|
+
// would change the answer, and what the portal replied is not repeated here.
|
|
97
|
+
<Notice
|
|
98
|
+
alert
|
|
99
|
+
icon={
|
|
100
|
+
<ShieldOff aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />
|
|
101
|
+
}
|
|
102
|
+
title={i18n.t("tools.noAccess")}
|
|
103
|
+
help={i18n.t("tools.noAccessHelp")}
|
|
104
|
+
/>
|
|
105
|
+
)
|
|
87
106
|
) : catalog.data.items.length === 0 ? (
|
|
88
107
|
<Notice
|
|
89
108
|
icon={<Wrench aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
|