@anchrd/intel-ui 0.4.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 +27 -0
- package/src/app/app-sidebar/app-sidebar.tsx +39 -24
- package/src/app/app-tree/app-tree.tsx +332 -60
- package/src/app/app.tsx +31 -5
- package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +2 -1
- package/src/app/tree-move/tree-move.tsx +331 -0
- package/src/app/user-footer/user-footer.tsx +73 -46
- package/src/app/view-toggle/view-toggle.tsx +77 -0
- package/src/blocknote-view/blocknote-view.tsx +19 -2
- package/src/branding/favicon.default.svg +2 -2
- package/src/branding/favicon.svg +2 -2
- package/src/components/ui/dropdown-menu.tsx +78 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +135 -57
- package/src/data/intel-data-provider/intel-data-provider.types.ts +51 -13
- package/src/document-link/document-link.tsx +132 -0
- package/src/flow-runs/flow-runs.tsx +225 -0
- package/src/flows/flows.tsx +665 -271
- package/src/flows/node-icon/node-icon.ts +28 -0
- package/src/flows/node-palette/node-palette.tsx +200 -0
- package/src/flows/node-palette/node-palette.types.ts +15 -0
- package/src/graph-pane/graph-pane.tsx +44 -0
- package/src/i18n/en.json +144 -29
- package/src/knowledge/knowledge.tsx +91 -367
- package/src/knowledge-editor/knowledge-editor.tsx +169 -21
- package/src/knowledge-graph/knowledge-graph.ts +26 -24
- package/src/knowledge-graph/knowledge-graph.tsx +33 -24
- package/src/knowledge-table/knowledge-table.tsx +141 -0
- package/src/main.tsx +2 -2
- package/src/resource-menu/resource-menu.tsx +615 -0
- package/src/router/selection-search.ts +27 -3
- package/src/save-button/save-button.tsx +103 -0
- package/src/styles.css +37 -0
- package/src/theme/theme.ts +24 -0
- package/src/title-row/title-row.tsx +49 -0
- package/src/tools/tools.tsx +57 -38
- package/src/app/header-actions/header-actions.tsx +0 -15
package/src/app/app.tsx
CHANGED
|
@@ -36,7 +36,17 @@ export function App() {
|
|
|
36
36
|
const [width, setWidth] = useState(stored.width);
|
|
37
37
|
|
|
38
38
|
return (
|
|
39
|
+
// ⚠️ `h-svh` is what keeps the box inside the screen. The registry's wrapper only carries
|
|
40
|
+
// `min-h-svh`, so its height follows its content — and the inset, an `inset` variant with a
|
|
41
|
+
// margin all round, then stands 16px taller than the viewport at best and grows with a long
|
|
42
|
+
// document at worst. Both times the lower border and its two rounded corners end up under the
|
|
43
|
+
// fold and the whole page scrolls. With a definite height the inset stretches to
|
|
44
|
+
// `100svh - 16px`, and the overflow below belongs to the container around the outlet, which
|
|
45
|
+
// already carries `min-h-0 overflow-auto` for exactly that. Set here rather than in
|
|
46
|
+
// `components/ui/sidebar.tsx`: that is registry code and an edit in it is silently gone at the
|
|
47
|
+
// next sync.
|
|
39
48
|
<SidebarProvider
|
|
49
|
+
className="h-svh"
|
|
40
50
|
open={open}
|
|
41
51
|
onOpenChange={(next) => {
|
|
42
52
|
setOpen(next);
|
|
@@ -55,7 +65,12 @@ export function App() {
|
|
|
55
65
|
`min-width: auto`, so it never shrinks below its content's min-content width. One wide
|
|
56
66
|
child (a flow canvas, a table of raw JSON) would widen the page instead of scrolling
|
|
57
67
|
inside its own container. Learned in gate, GATE-76 D. */}
|
|
58
|
-
|
|
68
|
+
{/* `overflow-hidden` is the second half of the rounding: the box is rounded but nothing
|
|
69
|
+
clipped its children, so an opaque panel inside it — the flow editor's inspector carries
|
|
70
|
+
`bg-card` — painted its own square corner over the rounded one. Invisible in the light
|
|
71
|
+
theme, where card and background are the same white, and plain to see in the dark one.
|
|
72
|
+
Scrolling is unaffected: the container around the outlet below owns it. */}
|
|
73
|
+
<SidebarInset className="min-w-0 overflow-hidden md:border">
|
|
59
74
|
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
|
|
60
75
|
<SidebarTrigger className="-ml-1" aria-label={i18n.t("shell.toggleSidebar")} />
|
|
61
76
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
|
@@ -76,10 +91,21 @@ export function App() {
|
|
|
76
91
|
) : null}
|
|
77
92
|
</BreadcrumbList>
|
|
78
93
|
</Breadcrumb>
|
|
79
|
-
{/* The bar for area-owned actions. A screen renders into
|
|
80
|
-
search is the shell's own, because it has to open from every screen
|
|
81
|
-
to none of them. It stays
|
|
82
|
-
|
|
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" />
|
|
83
109
|
<HeaderSearch />
|
|
84
110
|
</div>
|
|
85
111
|
</header>
|
|
@@ -40,7 +40,8 @@ export function SidebarResizeHandle({
|
|
|
40
40
|
"after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2",
|
|
41
41
|
"hover:after:bg-sidebar-ring focus-visible:after:bg-sidebar-ring",
|
|
42
42
|
"focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
|
43
|
-
// Collapsed
|
|
43
|
+
// Collapsed there is no sidebar left to drag. The shell already unmounts the whole panel
|
|
44
|
+
// then; this keeps the handle from reappearing should it ever be mounted collapsed.
|
|
44
45
|
"group-data-[state=collapsed]:hidden",
|
|
45
46
|
)}
|
|
46
47
|
onPointerDown={(event) => {
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { ChevronLeft, Folder } from "lucide-react";
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
6
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
7
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
8
|
+
|
|
9
|
+
// Where a row would land. `null` is the root of the shared tree; the title is carried along because
|
|
10
|
+
// the dialog names the destination and the row that supplied it is not always still on screen.
|
|
11
|
+
export type MoveDestination = { id: string | null; title: string };
|
|
12
|
+
|
|
13
|
+
export type MoveVerdict = "ok" | "self" | "descendant" | "same-place";
|
|
14
|
+
|
|
15
|
+
// The parent a row is filed under. Knowledge and Flows keep their own record (ADR-0004), so the
|
|
16
|
+
// answer is read from whichever one this row is, never from a merged shape.
|
|
17
|
+
export function parentOf(entry: TreeEntry): string | null {
|
|
18
|
+
return entry.type === "knowledge" ? entry.node.parentId : entry.flow.parentId;
|
|
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
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ⚠️ The trap this ticket is built around: a node must not travel into its own descendants. The
|
|
37
|
+
* service refuses it (`move_cycle`, `knowledge.ts:421`), but a refusal that only arrives once the
|
|
38
|
+
* mouse button is already up is the one answer worse than no dragging at all — the drop has to be
|
|
39
|
+
* offered or withheld while the pointer is still moving.
|
|
40
|
+
*
|
|
41
|
+
* It costs no request. The tree already carries the ancestor path of every rendered row for its own
|
|
42
|
+
* cycle guard (`app-tree.tsx`), and "is this target inside the row I am dragging" is exactly the
|
|
43
|
+
* question that path answers: the dragged id appears above the target, or it does not.
|
|
44
|
+
*/
|
|
45
|
+
export function moveVerdict(input: {
|
|
46
|
+
draggedId: string;
|
|
47
|
+
draggedParentId: string | null;
|
|
48
|
+
targetId: string | null;
|
|
49
|
+
targetAncestors: ReadonlySet<string>;
|
|
50
|
+
}): MoveVerdict {
|
|
51
|
+
if (input.targetId === input.draggedId) return "self";
|
|
52
|
+
if (input.targetId !== null && input.targetAncestors.has(input.draggedId)) return "descendant";
|
|
53
|
+
if (input.targetId === input.draggedParentId) return "same-place";
|
|
54
|
+
return "ok";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ⚠️ Four refusals, four sentences. The service tells them apart by `code`
|
|
58
|
+
// (`knowledge.ts:331/335/338/361`, `flows.ts:205–217/752`), and so does this: one message for all
|
|
59
|
+
// of them would leave the reader guessing which of "you may not write there", "that is not a
|
|
60
|
+
// folder", "that would be a loop" and "somebody else was faster" they have just hit.
|
|
61
|
+
export function moveErrorKey(error: unknown): string {
|
|
62
|
+
const code =
|
|
63
|
+
typeof error === "object" && error !== null && "code" in error
|
|
64
|
+
? String((error as { code: unknown }).code)
|
|
65
|
+
: null;
|
|
66
|
+
switch (code) {
|
|
67
|
+
case "knowledge_forbidden":
|
|
68
|
+
case "flow_edit_forbidden":
|
|
69
|
+
return "tree.move.failed.forbidden";
|
|
70
|
+
case "parent_not_folder":
|
|
71
|
+
return "tree.move.failed.notFolder";
|
|
72
|
+
case "move_cycle":
|
|
73
|
+
return "tree.move.failed.cycle";
|
|
74
|
+
case "update_conflict":
|
|
75
|
+
case "flow_update_conflict":
|
|
76
|
+
return "tree.move.failed.conflict";
|
|
77
|
+
case "flow_parent_not_found":
|
|
78
|
+
return "tree.move.failed.gone";
|
|
79
|
+
default:
|
|
80
|
+
return "tree.move.failed.other";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The move, named before it happens.
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ Since #16 a share hangs on the folder and reaches everything beneath it (`db-grants.ts`,
|
|
88
|
+
* `nodeVerbQuery`), so moving a document is a change of who may read it — without the word "share"
|
|
89
|
+
* appearing anywhere. That is why every move is confirmed rather than performed on drop.
|
|
90
|
+
*
|
|
91
|
+
* What this deliberately does not do is name the people. `listKnowledgeGrants` answers with the
|
|
92
|
+
* grants sitting on that one folder, needs `share` on it (`knowledge.ts:459`), and says nothing
|
|
93
|
+
* about the grants above it that reach down. A list built from that would be short of exactly the
|
|
94
|
+
* entries that matter and would read as complete — "nobody is on this folder" while an ancestor
|
|
95
|
+
* opens it to the whole organization. Naming the mechanism truthfully beats enumerating it wrongly;
|
|
96
|
+
* an effective-audience answer is the server's to give, and no endpoint offers one today.
|
|
97
|
+
*/
|
|
98
|
+
export function MoveDialog({
|
|
99
|
+
entry,
|
|
100
|
+
initial,
|
|
101
|
+
close,
|
|
102
|
+
submit,
|
|
103
|
+
}: {
|
|
104
|
+
entry: TreeEntry;
|
|
105
|
+
initial: MoveDestination | null;
|
|
106
|
+
close(): void;
|
|
107
|
+
submit(destination: MoveDestination): void;
|
|
108
|
+
}) {
|
|
109
|
+
const { data, i18n } = useIntelRouterContext();
|
|
110
|
+
const [destination, setDestination] = useState<MoveDestination | null>(initial);
|
|
111
|
+
// The browsed path, root first. It is also the descendant guard for the keyboard route: the row
|
|
112
|
+
// being moved is never offered, so its subtree can never be entered in the first place.
|
|
113
|
+
const [path, setPath] = useState<MoveDestination[]>([]);
|
|
114
|
+
const here: MoveDestination = path.at(-1) ?? { id: null, title: i18n.t("tree.move.root") };
|
|
115
|
+
const level = useQuery({
|
|
116
|
+
queryKey: ["tree", here.id],
|
|
117
|
+
queryFn: async () => await data.listTreeChildren(here.id),
|
|
118
|
+
enabled: destination === null,
|
|
119
|
+
});
|
|
120
|
+
const folders = (level.data ?? []).filter(
|
|
121
|
+
(child) => child.kind === "folder" && child.id !== entry.id,
|
|
122
|
+
);
|
|
123
|
+
const verdict = moveVerdict({
|
|
124
|
+
draggedId: entry.id,
|
|
125
|
+
draggedParentId: parentOf(entry),
|
|
126
|
+
targetId: here.id,
|
|
127
|
+
targetAncestors: new Set(path.map((step) => step.id).filter((id) => id !== null)),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<Modal title={i18n.t("tree.move.title", { title: entry.title })} close={close}>
|
|
132
|
+
{destination === null ? (
|
|
133
|
+
<div className="space-y-3">
|
|
134
|
+
<p className="text-sm text-muted-foreground">{i18n.t("tree.move.pick")}</p>
|
|
135
|
+
<div className="flex items-center gap-2">
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
disabled={path.length === 0}
|
|
139
|
+
onClick={() => setPath((current) => current.slice(0, -1))}
|
|
140
|
+
aria-label={i18n.t("tree.move.up")}
|
|
141
|
+
className="rounded-md border p-1 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
142
|
+
>
|
|
143
|
+
<ChevronLeft aria-hidden="true" className="size-4" />
|
|
144
|
+
</button>
|
|
145
|
+
<p className="min-w-0 flex-1 truncate text-sm font-medium">{here.title}</p>
|
|
146
|
+
</div>
|
|
147
|
+
<ul className="max-h-56 space-y-1 overflow-y-auto">
|
|
148
|
+
{level.isPending ? (
|
|
149
|
+
<li className="px-2 py-1.5 text-sm text-muted-foreground">
|
|
150
|
+
{i18n.t("common.loading")}
|
|
151
|
+
</li>
|
|
152
|
+
) : folders.length === 0 ? (
|
|
153
|
+
<li className="px-2 py-1.5 text-sm text-muted-foreground">
|
|
154
|
+
{i18n.t("tree.move.noFolders")}
|
|
155
|
+
</li>
|
|
156
|
+
) : (
|
|
157
|
+
folders.map((folder) => (
|
|
158
|
+
<li key={folder.id}>
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
onClick={() => setPath((current) => [...current, folder])}
|
|
162
|
+
aria-label={i18n.t("tree.move.open", { title: folder.title })}
|
|
163
|
+
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
164
|
+
>
|
|
165
|
+
<Folder aria-hidden="true" className="size-4 shrink-0" />
|
|
166
|
+
<span className="truncate">{folder.title}</span>
|
|
167
|
+
</button>
|
|
168
|
+
</li>
|
|
169
|
+
))
|
|
170
|
+
)}
|
|
171
|
+
</ul>
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
disabled={verdict !== "ok"}
|
|
175
|
+
onClick={() => setDestination(here)}
|
|
176
|
+
className="w-full 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 disabled:opacity-50"
|
|
177
|
+
>
|
|
178
|
+
{i18n.t("tree.move.here", { title: here.title })}
|
|
179
|
+
</button>
|
|
180
|
+
{verdict === "same-place" ? (
|
|
181
|
+
<p className="text-sm text-muted-foreground">{i18n.t("tree.move.alreadyThere")}</p>
|
|
182
|
+
) : null}
|
|
183
|
+
</div>
|
|
184
|
+
) : (
|
|
185
|
+
<div className="space-y-4">
|
|
186
|
+
<p className="text-sm">{i18n.t("tree.move.destination", { title: destination.title })}</p>
|
|
187
|
+
{/* Not a footnote: this is the only place the change of audience is stated, and it is
|
|
188
|
+
stated before the move, not in the audit log afterwards. */}
|
|
189
|
+
<p role="note" className="rounded-md border border-destructive/50 p-3 text-sm">
|
|
190
|
+
{i18n.t("tree.move.sharingWarning", { title: destination.title })}
|
|
191
|
+
</p>
|
|
192
|
+
<div className="flex gap-2">
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
onClick={() => setDestination(null)}
|
|
196
|
+
className="flex-1 rounded-md border px-4 py-2 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
197
|
+
>
|
|
198
|
+
{i18n.t("tree.move.elsewhere")}
|
|
199
|
+
</button>
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
onClick={() => submit(destination)}
|
|
203
|
+
className="flex-1 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"
|
|
204
|
+
>
|
|
205
|
+
{i18n.t("tree.move.confirm")}
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</Modal>
|
|
211
|
+
);
|
|
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
|
+
}
|
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
2
|
-
import {
|
|
3
|
-
import { LogOut, User, Wrench } from "lucide-react";
|
|
4
|
-
import {
|
|
2
|
+
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
3
|
+
import { ChevronsUpDown, LogOut, User, Wrench } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuSeparator,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from "@/components/ui/dropdown-menu";
|
|
11
|
+
import { SidebarMenu, SidebarMenuItem } from "@/components/ui/sidebar";
|
|
5
12
|
import { loginPath } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
6
13
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Everything that belongs to the person rather than to the content, behind the person (#24).
|
|
17
|
+
*
|
|
18
|
+
* The three rows this used to be — the tool catalogue, the name, the sign-out — spent three lines of
|
|
19
|
+
* sidebar on things nobody opens twice a day. The name and the address stay on screen, because they
|
|
20
|
+
* are the answer to "who am I signed in as", which is a reading and not an action; the two actions
|
|
21
|
+
* move behind them.
|
|
22
|
+
*
|
|
23
|
+
* The catalogue itself is a live portal query with the signed-in user's own token (ADR-0003), so
|
|
24
|
+
* what it shows depends on who is asking.
|
|
25
|
+
*/
|
|
11
26
|
export function UserFooter() {
|
|
12
27
|
const { data, i18n } = useIntelRouterContext();
|
|
28
|
+
const navigate = useNavigate();
|
|
13
29
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
14
30
|
const session = useQuery({ queryKey: ["session"], queryFn: () => data.getSession() });
|
|
15
31
|
const logout = useMutation({
|
|
@@ -18,59 +34,70 @@ export function UserFooter() {
|
|
|
18
34
|
});
|
|
19
35
|
|
|
20
36
|
const user = session.data ?? null;
|
|
21
|
-
const primary = user
|
|
37
|
+
const primary = user
|
|
38
|
+
? (user.name ?? user.email)
|
|
39
|
+
: session.isPending
|
|
40
|
+
? i18n.t("common.loading")
|
|
41
|
+
: i18n.t("shell.userUnavailable");
|
|
22
42
|
const secondary = user?.name ? user.email : null;
|
|
23
43
|
|
|
24
44
|
return (
|
|
25
45
|
<SidebarMenu>
|
|
26
46
|
<SidebarMenuItem>
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<span className="grid flex-1 leading-tight group-data-[collapsible=icon]:hidden">
|
|
46
|
-
<span className="truncate font-medium">
|
|
47
|
-
{session.isPending
|
|
48
|
-
? i18n.t("common.loading")
|
|
49
|
-
: (primary ?? i18n.t("shell.userUnavailable"))}
|
|
47
|
+
<DropdownMenu>
|
|
48
|
+
<DropdownMenuTrigger
|
|
49
|
+
aria-label={i18n.t("shell.userMenu", { name: primary })}
|
|
50
|
+
data-slot="signed-in-user"
|
|
51
|
+
data-active={pathname === "/tools"}
|
|
52
|
+
className="flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[active=true]:bg-sidebar-accent data-[state=open]:bg-sidebar-accent"
|
|
53
|
+
>
|
|
54
|
+
<span className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-accent text-sidebar-accent-foreground">
|
|
55
|
+
<User aria-hidden="true" className="size-4" />
|
|
56
|
+
</span>
|
|
57
|
+
{/* Read out in both states; sighted users see it in neither. It is what tells a screen
|
|
58
|
+
reader that the name below is a name and not a heading. */}
|
|
59
|
+
<span className="sr-only">{i18n.t("shell.signedInUser")}</span>
|
|
60
|
+
<span className="grid min-w-0 flex-1 leading-tight">
|
|
61
|
+
<span className="truncate font-medium">{primary}</span>
|
|
62
|
+
{secondary ? (
|
|
63
|
+
<span className="truncate text-xs text-muted-foreground">{secondary}</span>
|
|
64
|
+
) : null}
|
|
50
65
|
</span>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
<ChevronsUpDown aria-hidden="true" className="ml-auto size-4 shrink-0" />
|
|
67
|
+
</DropdownMenuTrigger>
|
|
68
|
+
{/* Upwards and as wide as its trigger: it opens from the bottom edge of the screen, and
|
|
69
|
+
a menu that has to be hunted for beside its button is a menu that was not found. */}
|
|
70
|
+
<DropdownMenuContent
|
|
71
|
+
side="top"
|
|
72
|
+
align="start"
|
|
73
|
+
className="w-(--radix-dropdown-menu-trigger-width) min-w-56"
|
|
74
|
+
>
|
|
75
|
+
<DropdownMenuItem onSelect={() => void navigate({ to: "/tools" })}>
|
|
76
|
+
<Wrench aria-hidden="true" />
|
|
77
|
+
{i18n.t("nav.tools")}
|
|
78
|
+
</DropdownMenuItem>
|
|
79
|
+
<DropdownMenuSeparator />
|
|
80
|
+
<DropdownMenuItem
|
|
81
|
+
disabled={logout.isPending}
|
|
82
|
+
onSelect={() => logout.mutate()}
|
|
83
|
+
className="text-destructive focus:text-destructive"
|
|
84
|
+
>
|
|
85
|
+
<LogOut aria-hidden="true" />
|
|
86
|
+
{i18n.t("auth.signOut")}
|
|
87
|
+
</DropdownMenuItem>
|
|
88
|
+
</DropdownMenuContent>
|
|
89
|
+
</DropdownMenu>
|
|
56
90
|
</SidebarMenuItem>
|
|
91
|
+
{/* ⚠️ Outside the menu on purpose. Choosing sign-out closes the popup, so a refusal rendered
|
|
92
|
+
inside it would be gone in the same frame it was written — the one message that must
|
|
93
|
+
survive is the one saying you are still signed in. */}
|
|
57
94
|
{logout.isError ? (
|
|
58
95
|
<SidebarMenuItem>
|
|
59
|
-
<p role="alert" className="px-2 text-sm text-destructive">
|
|
96
|
+
<p role="alert" className="px-2 py-1 text-sm text-destructive">
|
|
60
97
|
{i18n.t("auth.signOutFailed")}
|
|
61
98
|
</p>
|
|
62
99
|
</SidebarMenuItem>
|
|
63
100
|
) : null}
|
|
64
|
-
<SidebarMenuItem>
|
|
65
|
-
<SidebarMenuButton
|
|
66
|
-
onClick={() => logout.mutate()}
|
|
67
|
-
disabled={logout.isPending}
|
|
68
|
-
tooltip={i18n.t("auth.signOut")}
|
|
69
|
-
>
|
|
70
|
-
<LogOut aria-hidden="true" />
|
|
71
|
-
<span>{i18n.t("auth.signOut")}</span>
|
|
72
|
-
</SidebarMenuButton>
|
|
73
|
-
</SidebarMenuItem>
|
|
74
101
|
</SidebarMenu>
|
|
75
102
|
);
|
|
76
103
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
2
|
+
import { History, Network, PencilRuler } from "lucide-react";
|
|
3
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
4
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
5
|
+
import { type IntelView, viewFrom } from "@/router/selection-search.ts";
|
|
6
|
+
|
|
7
|
+
const icons = { editor: PencilRuler, graph: Network, runs: History } as const;
|
|
8
|
+
const labels = {
|
|
9
|
+
editor: "view.showEditor",
|
|
10
|
+
graph: "view.showGraph",
|
|
11
|
+
runs: "view.showRuns",
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The switch between the ways the level the sidebar has selected can be shown: editor ↔ graph
|
|
16
|
+
* everywhere (#19), and editor ↔ graph ↔ runs on a flow (#35).
|
|
17
|
+
*
|
|
18
|
+
* **Head, not row — the decision and its reason.** The switch changes how the *current view* is
|
|
19
|
+
* shown, and the current view is what the header already names in its breadcrumb; a state of the
|
|
20
|
+
* view belongs beside its name. In the tree row it would have to live inside the row's overflow
|
|
21
|
+
* menu (#24), and switching back would mean opening that menu again — a toggle that costs two
|
|
22
|
+
* clicks in one direction and two in the other is no longer a toggle. The objection the ticket
|
|
23
|
+
* raises against the header is answered by not rendering the switch where there is no graph: a
|
|
24
|
+
* document, an attachment and the tools screen never mount it, so it cannot stand there with
|
|
25
|
+
* nothing to point at.
|
|
26
|
+
*
|
|
27
|
+
* **Icon, no text.** The visible label is gone and nothing else: the accessible name is on the
|
|
28
|
+
* button and repeated as a tooltip, so the header stops growing with every new view while a screen
|
|
29
|
+
* reader still hears the whole sentence.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ It offers the views you are *not* in, one button each, rather than cycling through them. With
|
|
32
|
+
* two views that is the single toggle #19 decided on, unchanged. With three, a cycle would put the
|
|
33
|
+
* runs two clicks away from the graph and make the button's meaning depend on where you already
|
|
34
|
+
* are — every destination stays one click, and every button keeps one name.
|
|
35
|
+
*/
|
|
36
|
+
export function ViewToggle({ views = ["editor", "graph"] }: { views?: readonly IntelView[] }) {
|
|
37
|
+
const { i18n } = useIntelRouterContext();
|
|
38
|
+
const navigate = useNavigate();
|
|
39
|
+
const state = useRouterState({
|
|
40
|
+
select: (current) => ({
|
|
41
|
+
pathname: current.location.pathname,
|
|
42
|
+
search: current.location.search as Record<string, unknown>,
|
|
43
|
+
view: viewFrom(current.location.search),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
return (
|
|
47
|
+
// Its own provider: the shell supplies one, but these buttons are portalled into the header
|
|
48
|
+
// from a screen and have to keep working wherever they are mounted.
|
|
49
|
+
<TooltipProvider delayDuration={300}>
|
|
50
|
+
{views
|
|
51
|
+
.filter((view) => view !== state.view)
|
|
52
|
+
.map((view) => {
|
|
53
|
+
const label = i18n.t(labels[view]);
|
|
54
|
+
const Icon = icons[view];
|
|
55
|
+
return (
|
|
56
|
+
<Tooltip key={view}>
|
|
57
|
+
<TooltipTrigger
|
|
58
|
+
type="button"
|
|
59
|
+
aria-label={label}
|
|
60
|
+
onClick={() => {
|
|
61
|
+
const { view: _view, ...rest } = state.search;
|
|
62
|
+
void navigate({
|
|
63
|
+
to: state.pathname,
|
|
64
|
+
search: view === "editor" ? rest : { ...rest, view },
|
|
65
|
+
});
|
|
66
|
+
}}
|
|
67
|
+
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"
|
|
68
|
+
>
|
|
69
|
+
<Icon aria-hidden="true" className="size-4" />
|
|
70
|
+
</TooltipTrigger>
|
|
71
|
+
<TooltipContent>{label}</TooltipContent>
|
|
72
|
+
</Tooltip>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</TooltipProvider>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -1,13 +1,30 @@
|
|
|
1
|
+
import { type DefaultReactSuggestionItem, getDefaultReactSlashMenuItems } from "@blocknote/react";
|
|
1
2
|
import { BlockNoteView as LibraryBlockNoteView } from "@blocknote/shadcn";
|
|
2
|
-
import type { ComponentType } from "react";
|
|
3
|
+
import type { ComponentType, ReactNode } from "react";
|
|
3
4
|
|
|
4
5
|
// BlockNote 0.52's generic view declaration conflicts with exactOptionalPropertyTypes even though
|
|
5
6
|
// useCreateBlockNote returns its matching default editor. Keep that upstream type seam in one place.
|
|
6
7
|
const CompatibleBlockNoteView = LibraryBlockNoteView as unknown as ComponentType<{
|
|
7
8
|
editor: unknown;
|
|
8
9
|
onChange(): void;
|
|
10
|
+
children?: ReactNode;
|
|
9
11
|
}>;
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
// `children` is how BlockNote mounts its own controllers — the slash menu among them — inside the
|
|
14
|
+
// editor's context. It travels through this seam rather than around it, so there is still one place
|
|
15
|
+
// that knows about the library's types.
|
|
16
|
+
export function BlockNoteView(props: { editor: unknown; onChange(): void; children?: ReactNode }) {
|
|
12
17
|
return <CompatibleBlockNoteView {...props} />;
|
|
13
18
|
}
|
|
19
|
+
|
|
20
|
+
// The same seam once more: the parameter type of BlockNote's own slash-menu helper does not accept
|
|
21
|
+
// an editor built from a schema of ours, because `heading`'s optional `isToggleable` prop fails the
|
|
22
|
+
// library's own `PropSchema` constraint under `exactOptionalPropertyTypes`. The editor is exactly
|
|
23
|
+
// what the helper wants at run time — it is the declaration that cannot say so.
|
|
24
|
+
const compatibleDefaultSlashMenuItems = getDefaultReactSlashMenuItems as unknown as (
|
|
25
|
+
editor: unknown,
|
|
26
|
+
) => DefaultReactSuggestionItem[];
|
|
27
|
+
|
|
28
|
+
export function defaultSlashMenuItems(editor: unknown): DefaultReactSuggestionItem[] {
|
|
29
|
+
return compatibleDefaultSlashMenuItems(editor);
|
|
30
|
+
}
|