@anchrd/intel-ui 0.11.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 (46) 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-models/agent-models.ts +142 -3
  9. package/src/agent/agent-models/agent-models.types.ts +18 -0
  10. package/src/agent/agent-profile/agent-profile.tsx +114 -31
  11. package/src/agent/agent.tsx +17 -9
  12. package/src/app/app-sidebar/app-sidebar.tsx +2 -2
  13. package/src/app/app-tree/app-tree.tsx +5 -3
  14. package/src/app/app.tsx +2 -2
  15. package/src/app/header-search/header-search.tsx +3 -1
  16. package/src/app/settings-dialog/settings-dialog.tsx +133 -0
  17. package/src/app/tree-move/tree-move.tsx +3 -1
  18. package/src/app/user-footer/user-footer.tsx +25 -2
  19. package/src/app/view-toggle/view-toggle.tsx +2 -2
  20. package/src/archive/archive.tsx +3 -1
  21. package/src/entry-picker/entry-picker.tsx +3 -1
  22. package/src/flow-runs/flow-runs.tsx +6 -3
  23. package/src/flows/flows.tsx +13 -9
  24. package/src/flows/node-palette/node-palette.tsx +2 -2
  25. package/src/folder-contents/folder-contents.tsx +3 -1
  26. package/src/graph-pane/graph-pane.tsx +2 -2
  27. package/src/i18n/de.json +402 -0
  28. package/src/i18n/en.json +17 -5
  29. package/src/i18n/es.json +402 -0
  30. package/src/i18n/i18n-context.tsx +65 -0
  31. package/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  32. package/src/i18n/i18n.ts +15 -11
  33. package/src/i18n/i18n.types.ts +43 -0
  34. package/src/main.tsx +33 -12
  35. package/src/modal/modal.tsx +2 -2
  36. package/src/node-table/node-table.tsx +3 -1
  37. package/src/nodes/nodes.tsx +7 -3
  38. package/src/resource-menu/resource-menu.tsx +8 -4
  39. package/src/router/router.types.ts +3 -2
  40. package/src/save-button/save-button.tsx +3 -3
  41. package/src/sign-in-refused/sign-in-refused.tsx +3 -2
  42. package/src/theme/theme-context.tsx +89 -0
  43. package/src/theme/theme.ts +51 -21
  44. package/src/timezone/timezone-context.tsx +45 -0
  45. package/src/timezone/timezone.ts +60 -0
  46. package/src/tools/tools.tsx +3 -1
@@ -10,7 +10,15 @@ import { Check, Copy, Info, KeyRound, MessageSquare, Plus, Trash2, Wrench } from
10
10
  import { useState } from "react";
11
11
  import { type AgentDefinitionHandle, asDraft } from "@/agent/agent-definition/agent-definition.ts";
12
12
  import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
13
- import { modelKey, parseModelKey, selectableModels } from "@/agent/agent-models/agent-models.ts";
13
+ import {
14
+ formatContextTokens,
15
+ formatPricePerMillion,
16
+ modelFacts,
17
+ modelKey,
18
+ parseModelKey,
19
+ selectableModels,
20
+ } from "@/agent/agent-models/agent-models.ts";
21
+ import type { ModelFacts } from "@/agent/agent-models/agent-models.types.ts";
14
22
  import {
15
23
  Select,
16
24
  SelectContent,
@@ -21,8 +29,11 @@ import {
21
29
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
22
30
  import { agentMcpAddress } from "@/data/agent-runtime/agent-runtime.ts";
23
31
  import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
32
+ import { useI18n } from "@/i18n/i18n-context.tsx";
24
33
  import { Modal } from "@/modal/modal.tsx";
25
34
  import { useIntelRouterContext } from "@/router/router-context.ts";
35
+ import { availableTimezones } from "@/timezone/timezone.ts";
36
+ import { useTimezone } from "@/timezone/timezone-context.tsx";
26
37
 
27
38
  // No dividers between sections — the space is the separation (#204). The explanation sits behind
28
39
  // the icon rather than under the title, so the page shows what the agent IS and keeps the prose
@@ -82,7 +93,7 @@ function SectionHint({ hint }: { hint: string }) {
82
93
  * versioned, shared and read into model context (ADR-0005 §4).
83
94
  */
84
95
  export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
85
- const { i18n } = useIntelRouterContext();
96
+ const i18n = useI18n();
86
97
  const definition = agent.definition;
87
98
 
88
99
  // The definition is `null` in exactly one window: the node exists and no version has been written
@@ -138,7 +149,8 @@ export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefiniti
138
149
  * not while they are describing what the agent should do.
139
150
  */
140
151
  function IdentitySection({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
141
- const { data, i18n } = useIntelRouterContext();
152
+ const { data } = useIntelRouterContext();
153
+ const i18n = useI18n();
142
154
  const queryClient = useQueryClient();
143
155
  const [confirming, setConfirming] = useState(false);
144
156
  const applicationId = agent.query.data?.applicationId ?? null;
@@ -217,7 +229,7 @@ function IdentitySection({ node, agent }: { node: Node; agent: AgentDefinitionHa
217
229
  * delete, is what says "built in, always there" without a sentence explaining it.
218
230
  */
219
231
  function ContactSection({ node }: { node: Node }) {
220
- const { i18n } = useIntelRouterContext();
232
+ const i18n = useI18n();
221
233
  const [copied, setCopied] = useState(false);
222
234
  // ⚠️ The runtime's own address, NOT intel's proxy. This one is dialled by a portal or another
223
235
  // agent with its own credential (ADR-0005 §7); intel's door is for this page's browser calls and
@@ -313,7 +325,7 @@ function KnowledgeSection({
313
325
  definition: AgentDefinition;
314
326
  agent: AgentDefinitionHandle;
315
327
  }) {
316
- const { i18n } = useIntelRouterContext();
328
+ const i18n = useI18n();
317
329
  const [adding, setAdding] = useState(false);
318
330
 
319
331
  function write(references: AgentReference[]) {
@@ -390,7 +402,7 @@ function ReferenceRow({
390
402
  setRole(role: AgentReferenceRole): void;
391
403
  remove(): void;
392
404
  }) {
393
- const { i18n } = useIntelRouterContext();
405
+ const i18n = useI18n();
394
406
  const { title, known } = useEntryTitle(reference.nodeId);
395
407
  return (
396
408
  <li className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
@@ -458,7 +470,8 @@ function ToolsSection({
458
470
  definition: AgentDefinition;
459
471
  agent: AgentDefinitionHandle;
460
472
  }) {
461
- const { data, i18n } = useIntelRouterContext();
473
+ const { data } = useIntelRouterContext();
474
+ const i18n = useI18n();
462
475
  const [adding, setAdding] = useState(false);
463
476
  const servers = useQuery({ queryKey: ["tool-servers"], queryFn: () => data.listToolServers() });
464
477
  const selected = definition.tools?.servers ?? [];
@@ -579,7 +592,7 @@ function ScheduleSection({
579
592
  definition: AgentDefinition;
580
593
  agent: AgentDefinitionHandle;
581
594
  }) {
582
- const { i18n } = useIntelRouterContext();
595
+ const i18n = useI18n();
583
596
  const [adding, setAdding] = useState(false);
584
597
 
585
598
  function write(schedules: AgentSchedule[]) {
@@ -593,10 +606,13 @@ function ScheduleSection({
593
606
  {definition.schedules.map((schedule, index) => (
594
607
  <li
595
608
  // biome-ignore lint/suspicious/noArrayIndexKey: same as the references above — an ordered array with no ids, edited by position
596
- key={`${schedule.cron}:${schedule.target.id}:${index}`}
609
+ key={`${schedule.cron}@${schedule.timezone}:${schedule.target.id}:${index}`}
597
610
  className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
598
611
  >
599
612
  <code className="rounded bg-muted px-2 py-1 text-xs">{schedule.cron}</code>
613
+ {/* The zone belongs beside the expression: "0 8 * * *" alone does not say eight
614
+ o'clock WHERE, and that is the whole difference this ticket is about. */}
615
+ <span className="text-xs text-muted-foreground">{schedule.timezone}</span>
600
616
  <EntryTitle entryId={schedule.target.id} flow={schedule.target.kind === "flow"} />
601
617
  <span className="text-xs text-muted-foreground">
602
618
  {i18n.t(`node.kind.${schedule.target.kind}`)}
@@ -637,8 +653,15 @@ function ScheduleSection({
637
653
  }
638
654
 
639
655
  function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSchedule): void }) {
640
- const { i18n } = useIntelRouterContext();
656
+ const i18n = useI18n();
641
657
  const [cron, setCron] = useState("0 8 * * *");
658
+ // ⚠️ The reader's preference is the SUGGESTION, and only at creation time (#228). What is stored
659
+ // is this value, so the schedule keeps meaning the same hour after its author travels — and it
660
+ // means the same hour to everybody else reading the agent.
661
+ const preferred = useTimezone();
662
+ const [timezone, setTimezone] = useState(preferred);
663
+ const zones = availableTimezones();
664
+ const options = zones.includes(timezone) ? zones : [timezone, ...zones];
642
665
  const [target, setTarget] = useState<{ id: string; kind: "document" | "flow" } | null>(null);
643
666
 
644
667
  return (
@@ -647,7 +670,7 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
647
670
  className="space-y-4"
648
671
  onSubmit={(event) => {
649
672
  event.preventDefault();
650
- if (target && cron.trim()) add({ cron: cron.trim(), target });
673
+ if (target && cron.trim()) add({ cron: cron.trim(), timezone, target });
651
674
  }}
652
675
  >
653
676
  <label className="block text-sm font-medium">
@@ -666,6 +689,25 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
666
689
  {i18n.t("agent.cronHint")}
667
690
  </span>
668
691
  </label>
692
+ <label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
693
+ {i18n.t("agent.scheduleTimezone")}
694
+ </label>
695
+ <select
696
+ id="agent-schedule-timezone"
697
+ value={timezone}
698
+ onChange={(event) => setTimezone(event.target.value)}
699
+ aria-describedby="agent-timezone-hint"
700
+ className="-mt-2 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
701
+ >
702
+ {options.map((zone) => (
703
+ <option key={zone} value={zone}>
704
+ {zone.replace(/_/g, " ")}
705
+ </option>
706
+ ))}
707
+ </select>
708
+ <span id="agent-timezone-hint" className="-mt-2 block text-xs text-muted-foreground">
709
+ {i18n.t("agent.scheduleTimezoneHint")}
710
+ </span>
669
711
  <EntryPicker
670
712
  kind="document"
671
713
  flows
@@ -697,30 +739,71 @@ function ModelSection({
697
739
  definition: AgentDefinition;
698
740
  agent: AgentDefinitionHandle;
699
741
  }) {
700
- const { i18n } = useIntelRouterContext();
742
+ const i18n = useI18n();
701
743
  const models = selectableModels(definition.model);
744
+ const current = modelFacts(definition.model);
702
745
 
703
746
  return (
704
747
  <Section title={i18n.t("agent.model")} hint={i18n.t("agent.modelHint")}>
705
- <Select
706
- value={modelKey(definition.model)}
707
- onValueChange={(key) => {
708
- const model = parseModelKey(key);
709
- if (model) agent.save.mutate({ ...asDraft(definition), model });
710
- }}
711
- >
712
- <SelectTrigger aria-label={i18n.t("agent.model")} className="w-full max-w-md">
713
- <SelectValue />
714
- </SelectTrigger>
715
- <SelectContent>
716
- {models.map((model) => (
717
- <SelectItem key={modelKey(model)} value={modelKey(model)}>
718
- {model.model}
719
- <span className="text-xs text-muted-foreground">{model.provider}</span>
720
- </SelectItem>
721
- ))}
722
- </SelectContent>
723
- </Select>
748
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
749
+ <Select
750
+ value={modelKey(definition.model)}
751
+ onValueChange={(key) => {
752
+ const model = parseModelKey(key);
753
+ if (model) agent.save.mutate({ ...asDraft(definition), model });
754
+ }}
755
+ >
756
+ <SelectTrigger aria-label={i18n.t("agent.model")} className="w-full max-w-md sm:w-72">
757
+ {/* ⚠️ Own children rather than the default mirror of the chosen item: the item carries
758
+ the figures too, and mirrored into the trigger they would stand twice in one row. */}
759
+ <SelectValue>
760
+ <ModelName facts={current} />
761
+ </SelectValue>
762
+ </SelectTrigger>
763
+ <SelectContent>
764
+ {models.map((model) => {
765
+ const facts = modelFacts(model);
766
+ return (
767
+ <SelectItem key={modelKey(model)} value={modelKey(model)}>
768
+ {/* ⚠️ The space is not formatting. The option's accessible name is its text run
769
+ together, so without it a screen reader reads "…70B Fast24k context". The eye
770
+ never notices, because the row is a flex box with a gap. */}
771
+ <ModelName facts={facts} /> <ModelFigures facts={facts} />
772
+ </SelectItem>
773
+ );
774
+ })}
775
+ </SelectContent>
776
+ </Select>
777
+ <ModelFigures facts={current} />
778
+ </div>
724
779
  </Section>
725
780
  );
726
781
  }
782
+
783
+ // Brand first, then the model's own name — "Claude Sonnet 5", not "claude-sonnet-5 · anthropic". The
784
+ // provider stays in the definition; it names who serves the model, which is not what anybody reads a
785
+ // list of models for.
786
+ function ModelName({ facts }: { facts: ModelFacts }) {
787
+ return (
788
+ <span className="truncate">
789
+ <span className="font-medium">{facts.brand}</span> {facts.name}
790
+ </span>
791
+ );
792
+ }
793
+
794
+ // The two numbers a model is actually chosen on. Absent for a model this installation has no figures
795
+ // for — the row then simply ends, because a made-up zero would be a false statement about money.
796
+ function ModelFigures({ facts }: { facts: ModelFacts }) {
797
+ const i18n = useI18n();
798
+ if (facts.contextTokens === null || facts.price === null) return null;
799
+
800
+ return (
801
+ <span className="text-xs whitespace-nowrap text-muted-foreground">
802
+ {i18n.t("agent.modelFigures", {
803
+ context: formatContextTokens(facts.contextTokens),
804
+ input: formatPricePerMillion(facts.price.inputPerMillion, i18n.locale),
805
+ output: formatPricePerMillion(facts.price.outputPerMillion, i18n.locale),
806
+ })}
807
+ </span>
808
+ );
809
+ }
@@ -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) => ({