@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -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] : ["tree", 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
- const [moving, setMoving] = useState<{
107
- entry: TreeEntry;
108
- initial: MoveDestination | null;
109
- } | null>(null);
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
- startMove(carried, target);
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
- {/* ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no
563
- touch worth the name. A capability that exists only for a mouse is missing for
564
- everyone else, so moving has a second, equal route this menu's folder picker. #27
565
- opened the menu with that one item; the rest of the row's actions moved in here from
566
- the header, which is what #24 is. A row under a flow is what that flow calls, read out
567
- of its graph rather than from `parent_id` (ADR-0004 §3): it has no place of its own to
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={() => startMove(dragged, { id: null, title: i18n.t("tree.move.root") })}
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.isError ? (
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
- {moving ? (
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 it through `ActionSlot`;
95
- search is the shell's own, because it has to open from every screen alike and belongs
96
- to none of them. It stays first, so a screen's actions line up to its right. */}
97
- <div data-slot="header-actions" className="ml-auto flex items-center gap-2">
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: { fetch?: typeof fetch; baseUrl?: string; onUnauthorized?(): void } = {},
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
- portalConnectUrl(returnTo = "/tools") {
404
- return `${baseUrl}/auth/connect?returnTo=${encodeURIComponent(returnTo)}`;
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
- portalConnectUrl(returnTo?: string): string;
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>;
@@ -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
- <div className="flex items-start justify-between gap-5 border-b px-6 py-4">
668
- <div className="min-w-0">
669
- <h2 className="truncate text-lg font-semibold">{flow.title}</h2>
670
- {flow.description ? (
671
- <p className="mt-1 text-sm text-muted-foreground">{flow.description}</p>
672
- ) : null}
673
- </div>
674
- <div className="flex shrink-0 items-center gap-2">
675
- <TooltipProvider delayDuration={300}>
676
- <Tooltip>
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
- <button
683
- type="button"
684
- onClick={onPublish}
685
- disabled={!publishable}
686
- aria-label={publishLabel}
687
- 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 ${
688
- publishable && unpublished ? "border-primary text-primary" : ""
689
- }`}
690
- >
691
- <Send aria-hidden="true" className="size-4" />
692
- </button>
693
- </span>
694
- </TooltipTrigger>
695
- <TooltipContent>{publishLabel}</TooltipContent>
696
- </Tooltip>
697
- </TooltipProvider>
698
- <ResourceMenu target={{ type: "flow", flow }} variant="title" />
699
- <SaveButton dirty={dirty && canMutate} saving={saving} onSave={onSave} />
700
- <button
701
- type="button"
702
- onClick={onRun}
703
- disabled={!canMutate || unpublished || running}
704
- 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"
705
- >
706
- {i18n.t("flows.run")}
707
- </button>
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
- // ⚠️ `w-max` below: an absolutely positioned box shrink-wraps its widest child, and collapsed that
115
- // is the 36px trigger. Without it the open bar inherits those 36px as its own maximum and folds
116
- // seven entries into a single column — the flex row wraps after every item.
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 className="absolute left-4 top-4 z-10 w-max max-w-[calc(100%-2rem)]">
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="inline-flex size-9 items-center justify-center rounded-lg border bg-card/95 text-card-foreground shadow-sm outline-none backdrop-blur hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
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
- className="mt-2 flex max-w-full flex-wrap gap-1 rounded-lg border bg-card/95 p-2 shadow-sm backdrop-blur"
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
- className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 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"
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.connect": "Connect the portal",
142
- "tools.reconnect": "Reconnect the portal",
143
- "tools.disconnected": "Portal not connected",
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
- {graphable && selection.graph ? (
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, the same one the
93
- tree row carries the point of #24 was that there be one place per action, not a
94
- second row of them here. Saving joins it from the strip it used to own below
95
- (`title-actions`), so the editor starts one screen row higher than it did.
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
- <div className="flex items-start justify-between gap-5 border-b px-6 py-4">
100
- <div className="min-w-0">
101
- <h2 className="truncate text-lg font-semibold">{selected.title}</h2>
102
- {selected.description && (
103
- <p className="mt-1 text-sm text-muted-foreground">{selected.description}</p>
104
- )}
105
- </div>
106
- <div className="flex shrink-0 items-center gap-2">
107
- {selected.kind !== "folder" && (
108
- <TooltipProvider delayDuration={300}>
109
- <Tooltip>
110
- <TooltipTrigger
111
- type="button"
112
- onClick={() => setVersionsOpen((value) => !value)}
113
- aria-label={i18n.t("knowledge.versions")}
114
- aria-expanded={versionsOpen}
115
- 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"
116
- >
117
- <History aria-hidden="true" className="size-4" />
118
- </TooltipTrigger>
119
- <TooltipContent>{i18n.t("knowledge.versions")}</TooltipContent>
120
- </Tooltip>
121
- </TooltipProvider>
122
- )}
123
- <ResourceMenu target={{ type: "knowledge", node: selected }} variant="title" />
124
- <div data-slot="title-actions" className="flex items-center gap-2" />
125
- </div>
126
- </div>
127
- {selected.kind === "folder" ? (
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
- <div className="flex items-center justify-between gap-4 border-b px-4 py-2">
47
- <p className="text-sm text-muted-foreground">
48
- {table.data
49
- ? i18n.t("knowledge.table.summary", {
50
- rows: table.data.rows.length,
51
- columns: table.data.columns.length,
52
- })
53
- : ""}
54
- </p>
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 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
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
- </div>
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
- * One component for both places it appears the tree row in the sidebar and the title line of the
32
- * open document or flow because they are the same menu on the same thing. Two components would be
33
- * the loose icon row this ticket removes, only spread over two files instead of one.
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. In the sidebar it follows the plus exactly hidden
81
- * until the row is hovered or something in it takes focus, always there below `md` because a
82
- * capability a keyboard cannot reach is missing for everyone who does not use a mouse. In a title
83
- * line it is a plain icon button beside the other header actions.
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
- {onMove ? (
211
- <DropdownMenuItem onSelect={onMove}>
212
- <CornerLeftUp aria-hidden="true" />
213
- {i18n.t("tree.move.action")}
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 whether the tree or the title line asked. */}
290
+ the screen instead, where it is read whatever the title line asked for. */}
268
291
  {failure !== undefined && failure !== null ? (
269
- <p
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
+ }
@@ -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, LogIn, PlugZap, Wrench } from "lucide-react";
5
- import type * as React from "react";
6
- import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
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 portal connect flow returns here with a marker when it refuses. Only its presence is used:
32
- // the value is attacker-controlled and could carry provider detail, so it is never rendered.
33
- const connectFailed =
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
- <Notice
78
- icon={<PlugZap aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
79
- title={i18n.t("tools.disconnected")}
80
- help={i18n.t("tools.disconnectedHelp")}
81
- >
82
- <a href={data.portalConnectUrl("/tools")} className={buttonVariants()}>
83
- <LogIn aria-hidden="true" />
84
- {i18n.t("tools.connect")}
85
- </a>
86
- </Notice>
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" />}