@anchrd/intel-ui 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +3 -2
  2. package/src/agent/agent-calendar/agent-calendar.tsx +32 -14
  3. package/src/agent/agent-chat/agent-chat.tsx +3 -1
  4. package/src/agent/agent-cron/agent-cron.ts +49 -113
  5. package/src/agent/agent-definition/agent-definition.ts +18 -9
  6. package/src/agent/agent-entry-title/agent-entry-title.ts +3 -1
  7. package/src/agent/agent-log/agent-log.tsx +6 -3
  8. package/src/agent/agent-profile/agent-profile.tsx +46 -12
  9. package/src/agent/agent.tsx +17 -9
  10. package/src/app/app-sidebar/app-sidebar.tsx +2 -2
  11. package/src/app/app-tree/app-tree.tsx +5 -3
  12. package/src/app/app.tsx +2 -2
  13. package/src/app/header-search/header-search.tsx +3 -1
  14. package/src/app/settings-dialog/settings-dialog.tsx +133 -0
  15. package/src/app/tree-move/tree-move.tsx +3 -1
  16. package/src/app/user-footer/user-footer.tsx +25 -2
  17. package/src/app/view-toggle/view-toggle.tsx +2 -2
  18. package/src/archive/archive.tsx +3 -1
  19. package/src/entry-picker/entry-picker.tsx +3 -1
  20. package/src/flow-runs/flow-runs.tsx +6 -3
  21. package/src/flows/flows.tsx +13 -9
  22. package/src/flows/node-palette/node-palette.tsx +2 -2
  23. package/src/folder-contents/folder-contents.tsx +3 -1
  24. package/src/graph-pane/graph-pane.tsx +2 -2
  25. package/src/i18n/de.json +402 -0
  26. package/src/i18n/en.json +16 -5
  27. package/src/i18n/es.json +402 -0
  28. package/src/i18n/i18n-context.tsx +65 -0
  29. package/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  30. package/src/i18n/i18n.ts +15 -11
  31. package/src/i18n/i18n.types.ts +43 -0
  32. package/src/main.tsx +33 -12
  33. package/src/modal/modal.tsx +2 -2
  34. package/src/node-table/node-table.tsx +3 -1
  35. package/src/nodes/nodes.tsx +7 -3
  36. package/src/resource-menu/resource-menu.tsx +8 -4
  37. package/src/router/router.types.ts +3 -2
  38. package/src/save-button/save-button.tsx +3 -3
  39. package/src/sign-in-refused/sign-in-refused.tsx +3 -2
  40. package/src/theme/theme-context.tsx +89 -0
  41. package/src/theme/theme.ts +51 -21
  42. package/src/timezone/timezone-context.tsx +45 -0
  43. package/src/timezone/timezone.ts +60 -0
  44. package/src/tools/tools.tsx +3 -1
@@ -24,6 +24,7 @@ import {
24
24
  } from "@/components/ui/dropdown-menu";
25
25
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
26
26
  import { useCapabilities } from "@/hooks/use-capabilities.ts";
27
+ import { useI18n } from "@/i18n/i18n-context.tsx";
27
28
  import { useIntelRouterContext } from "@/router/router-context.ts";
28
29
 
29
30
  const tabs = ["profile", "chat", "calendar", "log"] as const;
@@ -43,7 +44,7 @@ type Tab = (typeof tabs)[number];
43
44
  * answer — a sentence in the two tabs that would have needed it — rather than as a red failure.
44
45
  */
45
46
  export function AgentPanel({ node }: { node: Node }) {
46
- const { i18n } = useIntelRouterContext();
47
+ const i18n = useI18n();
47
48
  const [tab, setTab] = useState<Tab>("profile");
48
49
  const agent = useAgentDefinition(node.id);
49
50
  const state = useAgentState(node.id);
@@ -115,7 +116,7 @@ export function AgentPanel({ node }: { node: Node }) {
115
116
  * whoever runs the installation over a working system.
116
117
  */
117
118
  function NotForYou() {
118
- const { i18n } = useIntelRouterContext();
119
+ const i18n = useI18n();
119
120
  return (
120
121
  <p className="m-auto max-w-md px-6 py-5 text-center text-sm text-muted-foreground">
121
122
  {i18n.t("agent.notPermitted")}
@@ -131,7 +132,7 @@ function NotForYou() {
131
132
  * agents, which is the operator's choice, not the reader's problem to debug.
132
133
  */
133
134
  function NoRuntime() {
134
- const { i18n } = useIntelRouterContext();
135
+ const i18n = useI18n();
135
136
  return (
136
137
  <p className="m-auto max-w-md px-6 py-5 text-center text-sm text-muted-foreground">
137
138
  {i18n.t("agent.noRuntime")}
@@ -157,7 +158,8 @@ export function AgentStatus({
157
158
  agentId: string;
158
159
  definition: AgentDefinition | null;
159
160
  }) {
160
- const { data, i18n } = useIntelRouterContext();
161
+ const { data } = useIntelRouterContext();
162
+ const i18n = useI18n();
161
163
  const state = useAgentState(agentId);
162
164
  const runs = useQuery({
163
165
  queryKey: ["agent-runs", agentId],
@@ -180,12 +182,17 @@ export function AgentStatus({
180
182
  paused
181
183
  ? i18n.t("agent.pausedNoRuns")
182
184
  : next
183
- ? i18n.t("agent.nextRun", {
185
+ ? // ⚠️ Formatted in the SCHEDULE's zone and named with it (#228), not in the reader's.
186
+ // The agent fires at the hour its schedule says; showing that instant in the zone of
187
+ // whoever happens to be looking would put a different hour on screen from the one the
188
+ // agent keeps, and the difference only surfaces as "it ran at the wrong time".
189
+ i18n.t("agent.nextRun", {
184
190
  when: new Intl.DateTimeFormat(i18n.locale, {
185
191
  dateStyle: "medium",
186
192
  timeStyle: "short",
187
- timeZone: "UTC",
188
- }).format(next),
193
+ timeZone: next.timezone,
194
+ }).format(next.at),
195
+ zone: next.timezone,
189
196
  })
190
197
  : i18n.t("agent.noNextRun"),
191
198
  ];
@@ -213,7 +220,8 @@ export function AgentActions({
213
220
  agentId: string;
214
221
  definition: AgentDefinition | null;
215
222
  }) {
216
- const { data, i18n } = useIntelRouterContext();
223
+ const { data } = useIntelRouterContext();
224
+ const i18n = useI18n();
217
225
  const queryClient = useQueryClient();
218
226
  const state = useAgentState(agentId);
219
227
  const denied = agentAccessDenied(state.error);
@@ -313,7 +321,7 @@ function RunNow({
313
321
  describedBy: string | undefined;
314
322
  start(target: AgentScheduleTarget): void;
315
323
  }) {
316
- const { i18n } = useIntelRouterContext();
324
+ const i18n = useI18n();
317
325
  const only = targets.length === 1 ? targets[0] : undefined;
318
326
  const className =
319
327
  "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
@@ -13,7 +13,7 @@ import {
13
13
  SidebarMenuItem,
14
14
  useSidebar,
15
15
  } from "@/components/ui/sidebar";
16
- import { useIntelRouterContext } from "@/router/router-context.ts";
16
+ import { useI18n } from "@/i18n/i18n-context.tsx";
17
17
 
18
18
  // One tree is the whole navigation: documents and flows side by side in the folder they share, with
19
19
  // no Intelligence or Flows entry above them (ADR-0004). Tools stay below, next to the user, because
@@ -25,7 +25,7 @@ export function AppSidebar({
25
25
  width: number;
26
26
  onWidthChange(width: number): void;
27
27
  }) {
28
- const { i18n } = useIntelRouterContext();
28
+ const i18n = useI18n();
29
29
  // `offcanvas` rather than `icon`: the icon rail is the mode for a list of equal entries with one
30
30
  // icon each, and a tree says what it says through indentation, disclosure arrows and nesting —
31
31
  // none of which survives 48px. Collapsing exists to free the canvas, so the sidebar goes.
@@ -40,6 +40,7 @@ import {
40
40
  import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
41
41
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
42
42
  import { useCapabilities } from "@/hooks/use-capabilities.ts";
43
+ import { useI18n } from "@/i18n/i18n-context.tsx";
43
44
  import { kindIcons } from "@/kind-icon.ts";
44
45
  import { Modal } from "@/modal/modal.tsx";
45
46
  import { useIntelRouterContext } from "@/router/router-context.ts";
@@ -106,7 +107,8 @@ function levelKey(level: Level): readonly unknown[] {
106
107
  export const TreeEntryMediaType = "application/x-intel-tree-entry";
107
108
 
108
109
  export function AppTree() {
109
- const { data, i18n } = useIntelRouterContext();
110
+ const { data } = useIntelRouterContext();
111
+ const i18n = useI18n();
110
112
  const queryClient = useQueryClient();
111
113
  const navigate = useNavigate();
112
114
  const [expanded, setExpanded] = useState<readonly Level[]>([]);
@@ -720,7 +722,7 @@ function AddMenu({
720
722
  variant?: "icon" | "row";
721
723
  onSelect(kind: NewKind | "upload" | ImportKind): void;
722
724
  }) {
723
- const { i18n } = useIntelRouterContext();
725
+ const i18n = useI18n();
724
726
  // Whether this deployment runs an agent runtime (#190). Until the answer arrives the item is
725
727
  // absent rather than provisional: a menu entry that vanished after being seen was a lie, one that
726
728
  // appears a beat late was merely loading.
@@ -789,7 +791,7 @@ function CreateForm({
789
791
  pending: boolean;
790
792
  submit(title: string, columns: string[]): void;
791
793
  }) {
792
- const { i18n } = useIntelRouterContext();
794
+ const i18n = useI18n();
793
795
  const [title, setTitle] = useState("");
794
796
  const [columns, setColumns] = useState("");
795
797
  const parsedColumns = columnsOf(columns);
package/src/app/app.tsx CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  } from "@/components/ui/breadcrumb";
14
14
  import { Separator } from "@/components/ui/separator";
15
15
  import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
16
- import { useIntelRouterContext } from "@/router/router-context.ts";
16
+ import { useI18n } from "@/i18n/i18n-context.tsx";
17
17
 
18
18
  const sections = [
19
19
  { path: "/nodes", labelKey: "nav.nodes" },
@@ -22,7 +22,7 @@ const sections = [
22
22
  ] as const;
23
23
 
24
24
  export function App() {
25
- const { i18n } = useIntelRouterContext();
25
+ const i18n = useI18n();
26
26
  const pathname = useRouterState({ select: (state) => state.location.pathname });
27
27
  const current = sections.find(
28
28
  (section) => pathname === section.path || pathname.startsWith(`${section.path}/`),
@@ -18,6 +18,7 @@ import {
18
18
  DialogHeader,
19
19
  DialogTitle,
20
20
  } from "@/components/ui/dialog";
21
+ import { useI18n } from "@/i18n/i18n-context.tsx";
21
22
  import { useIntelRouterContext } from "@/router/router-context.ts";
22
23
 
23
24
  const Kinds = ["node", "flow", "tool"] as const;
@@ -50,7 +51,8 @@ function matches(query: string, ...fields: (string | null)[]): boolean {
50
51
  }
51
52
 
52
53
  export function HeaderSearch() {
53
- const { data, i18n } = useIntelRouterContext();
54
+ const { data } = useIntelRouterContext();
55
+ const i18n = useI18n();
54
56
  const navigate = useNavigate();
55
57
  const [open, setOpen] = useState(false);
56
58
  const [text, setText] = useState("");
@@ -0,0 +1,133 @@
1
+ import {
2
+ Dialog,
3
+ DialogContent,
4
+ DialogDescription,
5
+ DialogHeader,
6
+ DialogTitle,
7
+ } from "@/components/ui/dialog";
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from "@/components/ui/select";
15
+ import { useI18n, useLanguage } from "@/i18n/i18n-context.tsx";
16
+ import { languageName } from "@/i18n/i18n-languages/i18n-languages.ts";
17
+ import { type ThemeChoice, ThemeChoices } from "@/theme/theme.ts";
18
+ import { useTheme } from "@/theme/theme-context.tsx";
19
+ import { availableTimezones, browserTimezone } from "@/timezone/timezone.ts";
20
+ import { useTimezoneSelection } from "@/timezone/timezone-context.tsx";
21
+
22
+ // The settings that belong to the person rather than to the installation: language, appearance and
23
+ // timezone. All three are client state — they live in localStorage, not in the Intel HTTP surface —
24
+ // so they do not belong on a screen with a save button. A choice applies at once; a dialog that
25
+ // previews and only commits on "Save" would carry three states too many for three dropdowns.
26
+ //
27
+ // The dialog is opened from outside (the user footer holds the state), because its trigger is a
28
+ // DropdownMenuItem: Radix unmounts the menu content when an item is chosen and would take a dialog
29
+ // nested inside it along in the same frame.
30
+ interface SettingsDialogProps {
31
+ open: boolean;
32
+ onOpenChange(open: boolean): void;
33
+ }
34
+
35
+ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
36
+ const i18n = useI18n();
37
+ const language = useLanguage();
38
+ const theme = useTheme();
39
+ const timezone = useTimezoneSelection();
40
+
41
+ // ⚠️ The active zone is put in front of the list unless it is already there, and that is not
42
+ // belt-and-braces: `Intl.supportedValuesOf("timeZone")` returns only CANONICAL IANA names, and
43
+ // "UTC" is not among them (it is an alias of Etc/UTC). A reader whose browser reports "UTC" —
44
+ // every CI machine, plenty of containers and servers — would otherwise get a Select whose value
45
+ // matches no item, and Radix renders that as an EMPTY field. Found by CI, not by the test.
46
+ //
47
+ // The same line covers the engine without `supportedValuesOf` at all: the list is empty and the
48
+ // reader still has their own zone to look at.
49
+ const zones = availableTimezones();
50
+ const current = timezone?.current ?? browserTimezone();
51
+ const options = zones.includes(current) ? zones : [current, ...zones];
52
+
53
+ return (
54
+ <Dialog open={open} onOpenChange={onOpenChange}>
55
+ <DialogContent className="sm:max-w-md">
56
+ <DialogHeader>
57
+ <DialogTitle>{i18n.t("settings.title")}</DialogTitle>
58
+ <DialogDescription>{i18n.t("settings.description")}</DialogDescription>
59
+ </DialogHeader>
60
+ <div className="grid gap-6">
61
+ {/* Without a provider there is nothing to choose — then the block is absent rather than a
62
+ dead dropdown. The number of languages is deliberately NOT checked: there are always
63
+ at least the three built-in ones, so a condition on it would be dead code. */}
64
+ {language ? (
65
+ <div className="grid gap-2">
66
+ <label className="text-sm font-medium" htmlFor="settings-language">
67
+ {i18n.t("settings.language")}
68
+ </label>
69
+ <Select value={language.current} onValueChange={language.select}>
70
+ <SelectTrigger id="settings-language" className="w-full">
71
+ <SelectValue />
72
+ </SelectTrigger>
73
+ <SelectContent>
74
+ {language.codes.map((code) => (
75
+ <SelectItem key={code} value={code}>
76
+ {languageName(code)}
77
+ </SelectItem>
78
+ ))}
79
+ </SelectContent>
80
+ </Select>
81
+ </div>
82
+ ) : null}
83
+ {theme ? (
84
+ <div className="grid gap-2">
85
+ <label className="text-sm font-medium" htmlFor="settings-appearance">
86
+ {i18n.t("settings.appearance")}
87
+ </label>
88
+ {/* Radix hands out a raw string; the values come from the same list that fills the
89
+ menu, so the assertion here is the whole check. */}
90
+ <Select
91
+ value={theme.current}
92
+ onValueChange={(value) => theme.select(value as ThemeChoice)}
93
+ >
94
+ <SelectTrigger id="settings-appearance" className="w-full">
95
+ <SelectValue />
96
+ </SelectTrigger>
97
+ <SelectContent>
98
+ {ThemeChoices.map((choice) => (
99
+ <SelectItem key={choice} value={choice}>
100
+ {i18n.t(`settings.appearance.${choice}`)}
101
+ </SelectItem>
102
+ ))}
103
+ </SelectContent>
104
+ </Select>
105
+ </div>
106
+ ) : null}
107
+ {timezone ? (
108
+ <div className="grid gap-2">
109
+ <label className="text-sm font-medium" htmlFor="settings-timezone">
110
+ {i18n.t("settings.timezone")}
111
+ </label>
112
+ <Select value={timezone.current} onValueChange={timezone.select}>
113
+ <SelectTrigger id="settings-timezone" className="w-full">
114
+ <SelectValue />
115
+ </SelectTrigger>
116
+ {/* Several hundred zones: the list scrolls, and Radix keeps the selected one in
117
+ view when it opens. */}
118
+ <SelectContent className="max-h-72">
119
+ {options.map((zone) => (
120
+ <SelectItem key={zone} value={zone}>
121
+ {zone.replace(/_/g, " ")}
122
+ </SelectItem>
123
+ ))}
124
+ </SelectContent>
125
+ </Select>
126
+ <p className="text-xs text-muted-foreground">{i18n.t("settings.timezoneHint")}</p>
127
+ </div>
128
+ ) : null}
129
+ </div>
130
+ </DialogContent>
131
+ </Dialog>
132
+ );
133
+ }
@@ -3,6 +3,7 @@ import { ChevronLeft, Folder } from "lucide-react";
3
3
  import type * as React from "react";
4
4
  import { useState } from "react";
5
5
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
6
+ import { useI18n } from "@/i18n/i18n-context.tsx";
6
7
  import { Modal } from "@/modal/modal.tsx";
7
8
  import { useIntelRouterContext } from "@/router/router-context.ts";
8
9
 
@@ -106,7 +107,8 @@ export function MoveDialog({
106
107
  close(): void;
107
108
  submit(destination: MoveDestination): void;
108
109
  }) {
109
- const { data, i18n } = useIntelRouterContext();
110
+ const { data } = useIntelRouterContext();
111
+ const i18n = useI18n();
110
112
  const [destination, setDestination] = useState<MoveDestination | null>(initial);
111
113
  // The browsed path, root first. It is also the descendant guard for the keyboard route: the row
112
114
  // being moved is never offered, so its subtree can never be entered in the first place.
@@ -1,6 +1,14 @@
1
1
  import { useMutation, useQuery } from "@tanstack/react-query";
2
2
  import { useNavigate, useRouterState } from "@tanstack/react-router";
3
- import { Archive as ArchiveIcon, ChevronsUpDown, LogOut, User, Wrench } from "lucide-react";
3
+ import {
4
+ Archive as ArchiveIcon,
5
+ ChevronsUpDown,
6
+ LogOut,
7
+ Settings,
8
+ User,
9
+ Wrench,
10
+ } from "lucide-react";
11
+ import { useState } from "react";
4
12
  import {
5
13
  DropdownMenu,
6
14
  DropdownMenuContent,
@@ -10,7 +18,9 @@ import {
10
18
  } from "@/components/ui/dropdown-menu";
11
19
  import { SidebarMenu, SidebarMenuItem } from "@/components/ui/sidebar";
12
20
  import { loginPath } from "@/data/intel-data-provider/intel-data-provider.ts";
21
+ import { useI18n } from "@/i18n/i18n-context.tsx";
13
22
  import { useIntelRouterContext } from "@/router/router-context.ts";
23
+ import { SettingsDialog } from "../settings-dialog/settings-dialog.tsx";
14
24
 
15
25
  /**
16
26
  * Everything that belongs to the person rather than to the content, behind the person (#24).
@@ -24,9 +34,13 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
24
34
  * what it shows depends on who is asking.
25
35
  */
26
36
  export function UserFooter() {
27
- const { data, i18n } = useIntelRouterContext();
37
+ const { data } = useIntelRouterContext();
38
+ const i18n = useI18n();
28
39
  const navigate = useNavigate();
29
40
  const pathname = useRouterState({ select: (state) => state.location.pathname });
41
+ // The state lives here, not in the dialog: its trigger is a DropdownMenuItem, and Radix unmounts
42
+ // the menu content when an item is chosen — a dialog nested in there would go with it.
43
+ const [settingsOpen, setSettingsOpen] = useState(false);
30
44
  const session = useQuery({ queryKey: ["session"], queryFn: () => data.getSession() });
31
45
  const logout = useMutation({
32
46
  mutationFn: () => data.logout(),
@@ -83,6 +97,13 @@ export function UserFooter() {
83
97
  {i18n.t("archive.title")}
84
98
  </DropdownMenuItem>
85
99
  <DropdownMenuSeparator />
100
+ {/* Language, appearance and timezone belong to the person, like everything else behind
101
+ this menu — and unlike the two rows above, they change nothing anybody else sees. */}
102
+ <DropdownMenuItem onSelect={() => setSettingsOpen(true)}>
103
+ <Settings aria-hidden="true" />
104
+ {i18n.t("settings.title")}
105
+ </DropdownMenuItem>
106
+ <DropdownMenuSeparator />
86
107
  <DropdownMenuItem
87
108
  disabled={logout.isPending}
88
109
  onSelect={() => logout.mutate()}
@@ -93,6 +114,8 @@ export function UserFooter() {
93
114
  </DropdownMenuItem>
94
115
  </DropdownMenuContent>
95
116
  </DropdownMenu>
117
+ {/* ⚠️ Outside the DropdownMenu on purpose — see the state above. */}
118
+ <SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
96
119
  </SidebarMenuItem>
97
120
  {/* ⚠️ Outside the menu on purpose. Choosing sign-out closes the popup, so a refusal rendered
98
121
  inside it would be gone in the same frame it was written — the one message that must
@@ -1,7 +1,7 @@
1
1
  import { useNavigate, useRouterState } from "@tanstack/react-router";
2
2
  import { History, Network, PencilRuler } from "lucide-react";
3
3
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
4
- import { useIntelRouterContext } from "@/router/router-context.ts";
4
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
5
  import { type IntelView, viewFrom } from "@/router/selection-search.ts";
6
6
 
7
7
  const icons = { editor: PencilRuler, graph: Network, runs: History } as const;
@@ -34,7 +34,7 @@ const labels = {
34
34
  * are — every destination stays one click, and every button keeps one name.
35
35
  */
36
36
  export function ViewToggle({ views = ["editor", "graph"] }: { views?: readonly IntelView[] }) {
37
- const { i18n } = useIntelRouterContext();
37
+ const i18n = useI18n();
38
38
  const navigate = useNavigate();
39
39
  const state = useRouterState({
40
40
  select: (current) => ({
@@ -1,6 +1,7 @@
1
1
  import type { Flow, Node } from "@anchrd/intel-contract";
2
2
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
3
  import { ArchiveRestore } from "lucide-react";
4
+ import { useI18n } from "@/i18n/i18n-context.tsx";
4
5
  import { kindIcons } from "@/kind-icon.ts";
5
6
  import { useIntelRouterContext } from "@/router/router-context.ts";
6
7
 
@@ -17,7 +18,8 @@ interface ArchivedEntry {
17
18
  }
18
19
 
19
20
  export function Archive() {
20
- const { data, i18n } = useIntelRouterContext();
21
+ const { data } = useIntelRouterContext();
22
+ const i18n = useI18n();
21
23
  const queryClient = useQueryClient();
22
24
 
23
25
  const archived = useQuery({
@@ -2,6 +2,7 @@ import type { NodeKind } from "@anchrd/intel-contract";
2
2
  import { useQuery } from "@tanstack/react-query";
3
3
  import { ChevronRight, Folder, Search } from "lucide-react";
4
4
  import { useMemo, useState } from "react";
5
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
6
  import { useIntelRouterContext } from "@/router/router-context.ts";
6
7
 
7
8
  // What this picker offers, whichever side of the shared tree it came from. Deliberately the four
@@ -40,7 +41,8 @@ export function EntryPicker({
40
41
  onSelect(entryId: string, entryKind: PickerKind): void;
41
42
  label: string;
42
43
  }) {
43
- const { data, i18n } = useIntelRouterContext();
44
+ const { data } = useIntelRouterContext();
45
+ const i18n = useI18n();
44
46
  const [query, setQuery] = useState("");
45
47
  const [openFolder, setOpenFolder] = useState<string | null>(null);
46
48
  const graph = useQuery({
@@ -3,6 +3,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
3
3
  import { ChevronDown, ChevronRight, CornerDownRight } from "lucide-react";
4
4
  import { useState } from "react";
5
5
  import type { I18n } from "@/i18n/i18n.types.ts";
6
+ import { useI18n } from "@/i18n/i18n-context.tsx";
6
7
  import { useIntelRouterContext } from "@/router/router-context.ts";
7
8
 
8
9
  // One page is what a person reads before deciding, not what a database can return. The server caps
@@ -28,7 +29,8 @@ function formatDuration(ms: number, i18n: I18n): string {
28
29
  * whoever started it — and the person reading this list is not necessarily that person.
29
30
  */
30
31
  export function FlowRuns({ flowId }: { flowId: string }) {
31
- const { data, i18n } = useIntelRouterContext();
32
+ const { data } = useIntelRouterContext();
33
+ const i18n = useI18n();
32
34
  const [failedOnly, setFailedOnly] = useState(false);
33
35
  const [openRunId, setOpenRunId] = useState<string | null>(null);
34
36
  // The filter belongs in the key: "only the failed ones" is a different question with a different
@@ -112,7 +114,7 @@ export function FlowRuns({ flowId }: { flowId: string }) {
112
114
  }
113
115
 
114
116
  function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; toggle(): void }) {
115
- const { i18n } = useIntelRouterContext();
117
+ const i18n = useI18n();
116
118
  const failed = run.status === "failed";
117
119
  const Chevron = open ? ChevronDown : ChevronRight;
118
120
  return (
@@ -160,7 +162,8 @@ function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; tog
160
162
  // onto the canvas because a run took an immutable version and the editor shows the current one —
161
163
  // marking a node in the wrong graph would point confidently at the wrong step.
162
164
  function RunSteps({ runId }: { runId: string }) {
163
- const { data, i18n } = useIntelRouterContext();
165
+ const { data } = useIntelRouterContext();
166
+ const i18n = useI18n();
164
167
  const history = useQuery({
165
168
  queryKey: ["flow-run-steps", runId],
166
169
  queryFn: () => data.getFlowRunSteps(runId),
@@ -32,11 +32,12 @@ import { FlowRuns } from "@/flow-runs/flow-runs.tsx";
32
32
  import { nodeIcon } from "@/flows/node-icon/node-icon.ts";
33
33
  import { NodePalette, usePaletteOpen } from "@/flows/node-palette/node-palette.tsx";
34
34
  import { GraphPane } from "@/graph-pane/graph-pane.tsx";
35
+ import { useI18n } from "@/i18n/i18n-context.tsx";
35
36
  import { Modal } from "@/modal/modal.tsx";
36
37
  import { useIntelRouterContext } from "@/router/router-context.ts";
37
38
  import { selectedFrom, viewFrom } from "@/router/selection-search.ts";
38
39
  import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
39
- import { useSystemTheme } from "@/theme/theme.ts";
40
+ import { useResolvedTheme } from "@/theme/theme-context.tsx";
40
41
  import { TitleRow } from "@/title-row/title-row.tsx";
41
42
 
42
43
  type CanvasNode = ReactFlowNode<{ node: FlowNode }, "intel">;
@@ -89,7 +90,7 @@ const flowKindOfEntry: Record<string, FlowNode["kind"] | undefined> = {
89
90
  };
90
91
 
91
92
  function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
92
- const { i18n } = useIntelRouterContext();
93
+ const i18n = useI18n();
93
94
  const contract = data.node;
94
95
  const Icon = nodeIcon[contract.kind];
95
96
  const branching = contract.kind === "condition";
@@ -354,9 +355,10 @@ export function Flows() {
354
355
  }
355
356
 
356
357
  function FlowsEditor() {
357
- const { data, i18n } = useIntelRouterContext();
358
+ const { data } = useIntelRouterContext();
359
+ const i18n = useI18n();
358
360
  const flow = useReactFlow();
359
- const theme = useSystemTheme();
361
+ const theme = useResolvedTheme();
360
362
  const queryClient = useQueryClient();
361
363
  const navigate = useNavigate();
362
364
  const tools = useQuery({
@@ -714,7 +716,7 @@ function FlowTitle({
714
716
  onSave(): void;
715
717
  onPublish(): void;
716
718
  }) {
717
- const { i18n } = useIntelRouterContext();
719
+ const i18n = useI18n();
718
720
  // A draft that is already the published one freezes nothing, so publishing it would be a no-op
719
721
  // with a dialog in front of it.
720
722
  const publishable =
@@ -775,7 +777,8 @@ function PublishPreview({
775
777
  close(): void;
776
778
  onPublished(): Promise<void>;
777
779
  }) {
778
- const { data, i18n } = useIntelRouterContext();
780
+ const { data } = useIntelRouterContext();
781
+ const i18n = useI18n();
779
782
  const preview = useQuery({
780
783
  queryKey: ["flow-publish-preview", flowId, versionId],
781
784
  queryFn: () => data.previewFlowPublish({ flowId, versionId }),
@@ -858,7 +861,7 @@ function NodeInspector({
858
861
  tools: Array<{ name: string; title: string | null; fingerprint: string }>;
859
862
  flows: Flow[];
860
863
  }) {
861
- const { i18n } = useIntelRouterContext();
864
+ const i18n = useI18n();
862
865
  if (!node) return <p className="p-5 text-sm text-muted-foreground">{i18n.t("flows.inspect")}</p>;
863
866
  const contract = node.data.node;
864
867
  return (
@@ -1001,7 +1004,8 @@ function NodeInspector({
1001
1004
  // them. A standing "this flow has conflicts" badge is not offered on purpose — for tools it could
1002
1005
  // never be true, because the catalog is a live query with each user's own portal token (ADR-0003).
1003
1006
  function FlowNeeds({ flowId }: { flowId: string }) {
1004
- const { data, i18n } = useIntelRouterContext();
1007
+ const { data } = useIntelRouterContext();
1008
+ const i18n = useI18n();
1005
1009
  const needs = useQuery({
1006
1010
  queryKey: ["flow-requirements", flowId],
1007
1011
  queryFn: () => data.getFlowRequirements(flowId),
@@ -1082,7 +1086,7 @@ function SubflowVersionField({
1082
1086
  configuration: Extract<FlowNode, { kind: "subflow" }>["configuration"];
1083
1087
  update(fn: (node: FlowNode) => FlowNode): void;
1084
1088
  }) {
1085
- const { i18n } = useIntelRouterContext();
1089
+ const i18n = useI18n();
1086
1090
  const mode = configuration.version.mode;
1087
1091
  return (
1088
1092
  <div className="mt-4">
@@ -2,8 +2,8 @@ import { flowNodeLayer } from "@anchrd/intel-contract";
2
2
  import { Plus, X } from "lucide-react";
3
3
  import { Fragment, useId, useRef, useState } from "react";
4
4
  import { type NodeIcon, nodeIcon } from "@/flows/node-icon/node-icon.ts";
5
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
6
  import { cn } from "@/lib/utils.ts";
6
- import { useIntelRouterContext } from "@/router/router-context.ts";
7
7
  import type { NodePaletteProps, PaletteKind } from "./node-palette.types.ts";
8
8
 
9
9
  const storageKey = "intel.flow-palette";
@@ -48,7 +48,7 @@ export function NodePalette<K extends PaletteKind>({
48
48
  add,
49
49
  disabledReason,
50
50
  }: NodePaletteProps<K>) {
51
- const { i18n } = useIntelRouterContext();
51
+ const i18n = useI18n();
52
52
  const listId = useId();
53
53
  const triggerRef = useRef<HTMLButtonElement>(null);
54
54
  const listRef = useRef<HTMLDivElement>(null);
@@ -10,6 +10,7 @@ import {
10
10
  TableRow,
11
11
  } from "@/components/ui/table.tsx";
12
12
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
13
+ import { useI18n } from "@/i18n/i18n-context.tsx";
13
14
  import { kindIcons } from "@/kind-icon.ts";
14
15
  import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
15
16
  import { useIntelRouterContext } from "@/router/router-context.ts";
@@ -38,7 +39,8 @@ function targetOf(entry: TreeEntry): ResourceTarget {
38
39
  * not read pictures. A column repeating the icon in prose costs the width the title needs.
39
40
  */
40
41
  export function FolderContents({ folderId }: { folderId: string }) {
41
- const { data, i18n } = useIntelRouterContext();
42
+ const { data } = useIntelRouterContext();
43
+ const i18n = useI18n();
42
44
  const navigate = useNavigate();
43
45
  const level = useQuery({
44
46
  queryKey: treeLevelKey(folderId),
@@ -1,7 +1,7 @@
1
1
  import type { RelationGraph } from "@anchrd/intel-contract";
2
2
  import type { UseQueryResult } from "@tanstack/react-query";
3
3
  import { lazy, Suspense } from "react";
4
- import { useIntelRouterContext } from "@/router/router-context.ts";
4
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
5
 
6
6
  const RelationGraphView = lazy(async () => ({
7
7
  default: (await import("@/node-graph/node-graph.tsx")).RelationGraphView,
@@ -17,7 +17,7 @@ export function GraphPane({
17
17
  query: UseQueryResult<RelationGraph>;
18
18
  select(node: RelationGraph["nodes"][number]): void;
19
19
  }) {
20
- const { i18n } = useIntelRouterContext();
20
+ const i18n = useI18n();
21
21
  const loading = <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
22
22
  if (query.isPending) return loading;
23
23
  if (query.isError || !query.data) {