@anchrd/intel-ui 0.12.0 → 0.14.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-profile/agent-profile.tsx +37 -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 +115 -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/components/ui/popover.tsx +41 -0
  20. package/src/entry-picker/entry-picker.tsx +3 -1
  21. package/src/flow-runs/flow-runs.tsx +6 -3
  22. package/src/flows/flows.tsx +13 -9
  23. package/src/flows/node-palette/node-palette.tsx +2 -2
  24. package/src/folder-contents/folder-contents.tsx +3 -1
  25. package/src/graph-pane/graph-pane.tsx +2 -2
  26. package/src/i18n/de.json +406 -0
  27. package/src/i18n/en.json +20 -5
  28. package/src/i18n/es.json +406 -0
  29. package/src/i18n/i18n-context.tsx +65 -0
  30. package/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  31. package/src/i18n/i18n.ts +15 -11
  32. package/src/i18n/i18n.types.ts +43 -0
  33. package/src/main.tsx +33 -12
  34. package/src/modal/modal.tsx +2 -2
  35. package/src/node-table/node-table.tsx +3 -1
  36. package/src/nodes/nodes.tsx +7 -3
  37. package/src/resource-menu/resource-menu.tsx +8 -4
  38. package/src/router/router.types.ts +3 -2
  39. package/src/save-button/save-button.tsx +3 -3
  40. package/src/sign-in-refused/sign-in-refused.tsx +3 -2
  41. package/src/theme/theme-context.tsx +89 -0
  42. package/src/theme/theme.ts +51 -21
  43. package/src/timezone/timezone-combobox/timezone-combobox.tsx +149 -0
  44. package/src/timezone/timezone-context.tsx +45 -0
  45. package/src/timezone/timezone.ts +159 -0
  46. package/src/tools/tools.tsx +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@anchrd/intel-contract": "^0.7.0",
32
+ "@anchrd/intel-contract": "^0.8.0",
33
33
  "@assistant-ui/react": "^0.15.4",
34
34
  "@assistant-ui/react-ai-sdk": "^1.4.4",
35
35
  "@blocknote/core": "^0.52.1",
@@ -45,6 +45,7 @@
45
45
  "clsx": "^2.1.1",
46
46
  "cmdk": "^1.1.1",
47
47
  "colorjs.io": "^0.7.1",
48
+ "croner": "^10.0.1",
48
49
  "fflate": "^0.8.3",
49
50
  "graphology": "^0.26.0",
50
51
  "lucide-react": "^1.25.0",
@@ -1,7 +1,7 @@
1
1
  import type { AgentDefinition } from "@anchrd/intel-contract";
2
2
  import { CalendarClock } from "lucide-react";
3
3
  import { nextCronFires } from "@/agent/agent-cron/agent-cron.ts";
4
- import { useIntelRouterContext } from "@/router/router-context.ts";
4
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
5
 
6
6
  // How far ahead one schedule is shown. Enough to recognise a rhythm — daily, weekly, the first of
7
7
  // the month — and not so much that the page becomes a year planner nobody asked for.
@@ -14,9 +14,11 @@ const PerSchedule = 5;
14
14
  * calendar, so these times are this page's own reading of the same cron expressions — by the same
15
15
  * rules, pinned by `agent-cron.unit.ts` against the runtime's test.
16
16
  *
17
- * ⚠️ UTC, and it says so. The definition carries no timezone, so the runtime fires in UTC; showing
18
- * these in the reader's local zone would be a different hour from the one the agent keeps, and the
19
- * two would only be noticed when a schedule "ran at the wrong time".
17
+ * ⚠️ Each entry is drawn in ITS OWN schedule's timezone and names it (#228). Not in the reader's:
18
+ * the agent fires at the hour its schedule says, and an instant shown in the zone of whoever is
19
+ * looking would be a different hour from the one the agent keeps — a difference that only ever
20
+ * surfaces as "it ran at the wrong time". And not one zone for the whole list either, because two
21
+ * schedules of one agent may sit in different ones.
20
22
  */
21
23
  export function AgentCalendar({
22
24
  definition,
@@ -27,26 +29,39 @@ export function AgentCalendar({
27
29
  // does not have to travel in time to assert on it.
28
30
  now: Date;
29
31
  }) {
30
- const { i18n } = useIntelRouterContext();
32
+ const i18n = useI18n();
31
33
  const schedules = definition?.schedules ?? [];
32
34
  const upcoming = schedules
33
35
  .flatMap((schedule) =>
34
- nextCronFires(schedule.cron, now, PerSchedule).map((at) => ({ at, schedule })),
36
+ nextCronFires(schedule.cron, now, PerSchedule, schedule.timezone).map((at) => ({
37
+ at,
38
+ schedule,
39
+ })),
35
40
  )
36
41
  .sort((left, right) => left.at.getTime() - right.at.getTime());
37
42
 
38
- const format = new Intl.DateTimeFormat(i18n.locale, {
39
- dateStyle: "full",
40
- timeStyle: "short",
41
- timeZone: "UTC",
42
- });
43
+ // One formatter per zone rather than one per row: a DateTimeFormat is expensive to build, and a
44
+ // calendar of five entries per schedule would otherwise build one for each of them.
45
+ const formatters = new Map<string, Intl.DateTimeFormat>();
46
+ const formatIn = (zone: string, at: Date) => {
47
+ let format = formatters.get(zone);
48
+ if (!format) {
49
+ format = new Intl.DateTimeFormat(i18n.locale, {
50
+ dateStyle: "full",
51
+ timeStyle: "short",
52
+ timeZone: zone,
53
+ });
54
+ formatters.set(zone, format);
55
+ }
56
+ return format.format(at);
57
+ };
43
58
 
44
59
  return (
45
60
  <section
46
61
  aria-label={i18n.t("agent.calendar")}
47
62
  className="min-h-0 flex-1 overflow-y-auto px-6 py-5"
48
63
  >
49
- <p className="text-xs text-muted-foreground">{i18n.t("agent.calendarUtc")}</p>
64
+ <p className="text-xs text-muted-foreground">{i18n.t("agent.calendarZones")}</p>
50
65
  {schedules.length === 0 ? (
51
66
  <p className="mt-4 text-sm text-muted-foreground">{i18n.t("agent.scheduleEmpty")}</p>
52
67
  ) : upcoming.length === 0 ? (
@@ -59,13 +74,16 @@ export function AgentCalendar({
59
74
  <ol className="mt-4 space-y-2">
60
75
  {upcoming.map((entry) => (
61
76
  <li
62
- key={`${entry.schedule.cron}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
77
+ key={`${entry.schedule.cron}@${entry.schedule.timezone}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
63
78
  className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm"
64
79
  >
65
80
  <CalendarClock aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
66
81
  <time dateTime={entry.at.toISOString()} className="font-medium">
67
- {format.format(entry.at)}
82
+ {formatIn(entry.schedule.timezone, entry.at)}
68
83
  </time>
84
+ {/* The zone stands next to the time, not once above the list: without it the same
85
+ wall-clock hour from two schedules reads as a duplicate. */}
86
+ <span className="text-xs text-muted-foreground">{entry.schedule.timezone}</span>
69
87
  <code className="rounded bg-muted px-2 py-0.5 text-xs">{entry.schedule.cron}</code>
70
88
  <span className="text-xs text-muted-foreground">
71
89
  {i18n.t(`node.kind.${entry.schedule.target.kind}`)}
@@ -8,6 +8,7 @@ import {
8
8
  import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
9
9
  import { ArrowDown, Send } from "lucide-react";
10
10
  import { useMemo } from "react";
11
+ import { useI18n } from "@/i18n/i18n-context.tsx";
11
12
  import { useIntelRouterContext } from "@/router/router-context.ts";
12
13
 
13
14
  /**
@@ -25,7 +26,8 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
25
26
  * rather than from a bearer this page would have had to hold.
26
27
  */
27
28
  export function AgentChat({ agentId }: { agentId: string }) {
28
- const { data, i18n } = useIntelRouterContext();
29
+ const { data } = useIntelRouterContext();
30
+ const i18n = useI18n();
29
31
  // ⚠️ Built once per agent. A transport rebuilt on every render restarts the runtime underneath
30
32
  // assistant-ui and takes the open thread with it.
31
33
  const transport = useMemo(
@@ -1,132 +1,68 @@
1
+ import { Cron } from "croner";
2
+
1
3
  /**
2
- * When a five-field cron expression fires next, in UTC.
4
+ * When a five-field cron expression fires next, read in the schedule's own timezone.
3
5
  *
4
- * ⚠️ This is a second copy of `packages/agent/src/schedule/cron/cron.ts`, on purpose and with a
5
- * cost. The runtime imports nothing from the contract and this package cannot import a Worker
6
- * package, so there is no third place the matcher could live without inventing one for two callers.
7
- * What must never happen is the two disagreeing: a calendar that promises 08:00 while the alarm
8
- * fires at 09:00 is worse than a calendar that says nothing. `agent-cron.unit.ts` therefore pins
9
- * exactly the cases the runtime's own test pins — including the day-field OR rule, which is the
10
- * one an independent reimplementation gets wrong.
6
+ * ⚠️ This is still a second copy of `packages/agent/src/schedule/cron/cron.ts` the runtime imports
7
+ * nothing from the contract and this package cannot import a Worker package, so there remains no
8
+ * third place for it. What changed with #228 is WHAT is duplicated: both files are now a thin
9
+ * wrapper over croner, and the part that could actually disagree the matching, DST, the day-field
10
+ * OR rule is one implementation in the library.
11
11
  *
12
- * ⚠️ UTC, with no timezone option, because the definition carries none: `schedules[{cron, target}]`
13
- * and nothing else. Reading these in local time here would show a person a different hour from the
14
- * one the agent runs at.
12
+ * That was the real risk. The old comment put it plainly: "a calendar that promises 08:00 while the
13
+ * alarm fires at 09:00 is worse than a calendar that says nothing" and two independent
14
+ * reimplementations of timezone arithmetic is exactly how that happens.
15
15
  */
16
- interface Field {
17
- values: Set<number>;
18
- restricted: boolean;
19
- }
20
-
21
- function parseField(source: string, min: number, max: number): Field | null {
22
- const values = new Set<number>();
23
- let restricted = false;
24
- for (const part of source.split(",")) {
25
- const [range, stepText] = part.split("/");
26
- if (range === undefined || range === "") return null;
27
- const step = stepText === undefined ? 1 : Number(stepText);
28
- if (!Number.isInteger(step) || step < 1) return null;
29
- let from: number;
30
- let to: number;
31
- if (range === "*") {
32
- from = min;
33
- to = max;
34
- if (step !== 1) restricted = true;
35
- } else if (range.includes("-")) {
36
- const [fromText, toText] = range.split("-");
37
- from = Number(fromText);
38
- to = Number(toText);
39
- restricted = true;
40
- } else {
41
- from = Number(range);
42
- to = from;
43
- restricted = true;
44
- }
45
- if (!Number.isInteger(from) || !Number.isInteger(to) || from < min || to > max || from > to) {
46
- return null;
47
- }
48
- for (let value = from; value <= to; value += step) values.add(value);
49
- }
50
- return values.size > 0 ? { values, restricted } : null;
51
- }
52
16
 
53
- export interface CronExpression {
54
- minute: Field;
55
- hour: Field;
56
- dayOfMonth: Field;
57
- month: Field;
58
- dayOfWeek: Field;
59
- }
17
+ // One year is the search bound. An expression that matches nothing within a year — "31st of
18
+ // February" — has no next fire time, and saying so beats stepping forever.
19
+ const MaxYearsAhead = 1;
60
20
 
61
- export function parseCron(expression: string): CronExpression | null {
62
- const parts = expression.trim().split(/\s+/);
63
- if (parts.length !== 5) return null;
64
- // Sunday is 0 in the standard and 7 in several dialects; both are accepted and normalised below.
65
- const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [
66
- string,
67
- string,
68
- string,
69
- string,
70
- string,
71
- ];
72
- const fields = {
73
- minute: parseField(minute, 0, 59),
74
- hour: parseField(hour, 0, 23),
75
- dayOfMonth: parseField(dayOfMonth, 1, 31),
76
- month: parseField(month, 1, 12),
77
- dayOfWeek: parseField(dayOfWeek, 0, 7),
78
- };
79
- if (!fields.minute || !fields.hour || !fields.dayOfMonth || !fields.month || !fields.dayOfWeek) {
21
+ function schedule(expression: string, timezone: string): Cron | null {
22
+ try {
23
+ const job = new Cron(expression.trim(), { timezone, paused: true });
24
+ // croner also accepts six fields (with seconds). Intel's contract is five, and so is the hint
25
+ // shown next to the input a six-field expression must not quietly mean something else.
26
+ return expression.trim().split(/\s+/).length === 5 ? job : null;
27
+ } catch {
80
28
  return null;
81
29
  }
82
- if (fields.dayOfWeek.values.has(7)) fields.dayOfWeek.values.add(0);
83
- return fields as CronExpression;
84
30
  }
85
31
 
86
- export function matches(cron: CronExpression, date: Date): boolean {
87
- if (!cron.minute.values.has(date.getUTCMinutes())) return false;
88
- if (!cron.hour.values.has(date.getUTCHours())) return false;
89
- if (!cron.month.values.has(date.getUTCMonth() + 1)) return false;
90
- const dayOfMonth = cron.dayOfMonth.values.has(date.getUTCDate());
91
- const dayOfWeek = cron.dayOfWeek.values.has(date.getUTCDay());
92
- // ⚠️ The standard's oddity, kept on purpose: when both day fields are restricted they are ORed,
93
- // not ANDed. "0 0 1 * 1" is the first of the month AND every Monday, and a matcher that ANDs them
94
- // silently lists about four times fewer runs than the agent will actually make.
95
- if (cron.dayOfMonth.restricted && cron.dayOfWeek.restricted) return dayOfMonth || dayOfWeek;
96
- return dayOfMonth && dayOfWeek;
32
+ // ⚠️ The croner call is inside the try, not only the construction. An unknown timezone can surface
33
+ // either place depending on when croner first converts a date and an exception escaping here ends
34
+ // up inside a Durable Object alarm, which is the one place nobody is watching.
35
+ function runs(expression: string, after: Date, count: number, timezone: string): Date[] {
36
+ const job = schedule(expression, timezone);
37
+ if (!job) return [];
38
+ try {
39
+ return job.nextRuns(count, after);
40
+ } catch {
41
+ return [];
42
+ }
97
43
  }
98
44
 
99
- // One year of minutes is the search bound. An expression that matches nothing within a year — "31st
100
- // of February" has no next fire time, and saying so beats stepping forever.
101
- const MaxMinutesAhead = 366 * 24 * 60;
102
-
103
- export function nextCronFire(expression: string, after: Date): Date | null {
104
- const cron = parseCron(expression);
105
- if (!cron) return null;
106
- // Strictly after: a schedule that just fired must not match the same minute again.
107
- const start = Math.floor(after.getTime() / 60_000) * 60_000 + 60_000;
108
- for (let step = 0; step < MaxMinutesAhead; step += 1) {
109
- const candidate = new Date(start + step * 60_000);
110
- if (matches(cron, candidate)) return candidate;
111
- }
112
- return null;
45
+ export function nextCronFire(expression: string, after: Date, timezone: string): Date | null {
46
+ // Strictly after: a schedule that just fired must not match the same minute again. croner's
47
+ // `nextRuns(…, from)` is already exclusive of `from`.
48
+ const next = runs(expression, after, 1, timezone)[0];
49
+ if (!next) return null;
50
+ const limit = new Date(after.getTime());
51
+ limit.setUTCFullYear(limit.getUTCFullYear() + MaxYearsAhead);
52
+ return next > limit ? null : next;
113
53
  }
114
54
 
115
55
  /**
116
56
  * The next `count` fire times of one expression.
117
57
  *
118
- * ⚠️ Stepping from each hit rather than scanning once, so an expression that fires every minute
119
- * costs `count` iterations and not a year of them. An unparseable or never-matching expression
120
- * yields an empty list — the calendar says "nothing" rather than guessing.
58
+ * An unparseable or never-matching expression yields an empty list the caller says "nothing"
59
+ * rather than guessing.
121
60
  */
122
- export function nextCronFires(expression: string, after: Date, count: number): Date[] {
123
- const fires: Date[] = [];
124
- let cursor = after;
125
- for (let index = 0; index < count; index += 1) {
126
- const next = nextCronFire(expression, cursor);
127
- if (!next) break;
128
- fires.push(next);
129
- cursor = next;
130
- }
131
- return fires;
61
+ export function nextCronFires(
62
+ expression: string,
63
+ after: Date,
64
+ count: number,
65
+ timezone: string,
66
+ ): Date[] {
67
+ return runs(expression, after, count, timezone);
132
68
  }
@@ -70,18 +70,27 @@ export function useAgentDefinition(nodeId: string): AgentDefinitionHandle {
70
70
  }
71
71
 
72
72
  /**
73
- * When this agent next does something on its own, across all of its schedules.
73
+ * When this agent next does something on its own, across all of its schedules — and in which zone
74
+ * that time is meant.
74
75
  *
75
76
  * ⚠️ Derived from the cron expressions, never asked. The runtime arms one alarm at a time and does
76
- * not publish it, so this is a reading of the same expressions by the same rules — see
77
- * `agent-cron.ts` for why that duplication is pinned by a test rather than left to agree by luck.
77
+ * not publish it, so this is a reading of the same expressions by the same rules — since #228 both
78
+ * sides read them through croner, so the reading and the alarm cannot drift apart.
79
+ *
80
+ * ⚠️ The zone travels WITH the time. Two schedules of one agent may sit in different zones, so a
81
+ * single "all times are X" line above the screen would be a lie for one of them; the caller formats
82
+ * each instant in the zone it came back with.
78
83
  */
79
- export function nextRunAt(definition: AgentDefinition | null, now: Date): Date | null {
80
- const fires = (definition?.schedules ?? []).flatMap(
81
- (schedule) => nextCronFires(schedule.cron, now, 1)[0] ?? [],
82
- );
83
- return fires.reduce<Date | null>(
84
- (earliest, fire) => (earliest === null || fire < earliest ? fire : earliest),
84
+ export function nextRunAt(
85
+ definition: AgentDefinition | null,
86
+ now: Date,
87
+ ): { at: Date; timezone: string } | null {
88
+ const fires = (definition?.schedules ?? []).flatMap((schedule) => {
89
+ const at = nextCronFires(schedule.cron, now, 1, schedule.timezone)[0];
90
+ return at ? [{ at, timezone: schedule.timezone }] : [];
91
+ });
92
+ return fires.reduce<{ at: Date; timezone: string } | null>(
93
+ (earliest, fire) => (earliest === null || fire.at < earliest.at ? fire : earliest),
85
94
  null,
86
95
  );
87
96
  }
@@ -1,4 +1,5 @@
1
1
  import { useQuery } from "@tanstack/react-query";
2
+ import { useI18n } from "@/i18n/i18n-context.tsx";
2
3
  import { useIntelRouterContext } from "@/router/router-context.ts";
3
4
 
4
5
  /**
@@ -13,7 +14,8 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
13
14
  * "run now" menu in the page head, which has to name the targets it offers.
14
15
  */
15
16
  export function useEntryTitle(entryId: string, flow = false): { title: string; known: boolean } {
16
- const { data, i18n } = useIntelRouterContext();
17
+ const { data } = useIntelRouterContext();
18
+ const i18n = useI18n();
17
19
  const graph = useQuery({
18
20
  queryKey: ["node-graph"],
19
21
  queryFn: () => data.getNodeGraph(),
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
2
2
  import { ChevronDown, ChevronRight } from "lucide-react";
3
3
  import { useState } from "react";
4
4
  import type { AgentRun } from "@/data/agent-runtime/agent-runtime.ts";
5
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
6
  import { useIntelRouterContext } from "@/router/router-context.ts";
6
7
 
7
8
  /**
@@ -16,7 +17,8 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
16
17
  * correspondent, and the runtime deliberately stores neither where this list could reach it.
17
18
  */
18
19
  export function AgentLog({ agentId }: { agentId: string }) {
19
- const { data, i18n } = useIntelRouterContext();
20
+ const { data } = useIntelRouterContext();
21
+ const i18n = useI18n();
20
22
  const [openRunId, setOpenRunId] = useState<string | null>(null);
21
23
  const runs = useQuery({
22
24
  queryKey: ["agent-runs", agentId],
@@ -71,7 +73,7 @@ function RunRow({
71
73
  open: boolean;
72
74
  toggle(): void;
73
75
  }) {
74
- const { i18n } = useIntelRouterContext();
76
+ const i18n = useI18n();
75
77
  const failed = run.status === "failed";
76
78
  const Chevron = open ? ChevronDown : ChevronRight;
77
79
  return (
@@ -113,7 +115,8 @@ function RunRow({
113
115
  // The operational log of one run: what it tried, in order. Fetched only when a row is opened —
114
116
  // every run carries its own entries and loading all of them would be the whole log for one screen.
115
117
  function RunDetail({ agentId, runId }: { agentId: string; runId: string }) {
116
- const { data, i18n } = useIntelRouterContext();
118
+ const { data } = useIntelRouterContext();
119
+ const i18n = useI18n();
117
120
  const detail = useQuery({
118
121
  queryKey: ["agent-run", agentId, runId],
119
122
  queryFn: () => data.getAgentRun(agentId, runId),
@@ -29,8 +29,11 @@ import {
29
29
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
30
30
  import { agentMcpAddress } from "@/data/agent-runtime/agent-runtime.ts";
31
31
  import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
32
+ import { useI18n } from "@/i18n/i18n-context.tsx";
32
33
  import { Modal } from "@/modal/modal.tsx";
33
34
  import { useIntelRouterContext } from "@/router/router-context.ts";
35
+ import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
36
+ import { useTimezone } from "@/timezone/timezone-context.tsx";
34
37
 
35
38
  // No dividers between sections — the space is the separation (#204). The explanation sits behind
36
39
  // the icon rather than under the title, so the page shows what the agent IS and keeps the prose
@@ -90,7 +93,7 @@ function SectionHint({ hint }: { hint: string }) {
90
93
  * versioned, shared and read into model context (ADR-0005 §4).
91
94
  */
92
95
  export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
93
- const { i18n } = useIntelRouterContext();
96
+ const i18n = useI18n();
94
97
  const definition = agent.definition;
95
98
 
96
99
  // The definition is `null` in exactly one window: the node exists and no version has been written
@@ -146,7 +149,8 @@ export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefiniti
146
149
  * not while they are describing what the agent should do.
147
150
  */
148
151
  function IdentitySection({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
149
- const { data, i18n } = useIntelRouterContext();
152
+ const { data } = useIntelRouterContext();
153
+ const i18n = useI18n();
150
154
  const queryClient = useQueryClient();
151
155
  const [confirming, setConfirming] = useState(false);
152
156
  const applicationId = agent.query.data?.applicationId ?? null;
@@ -225,7 +229,7 @@ function IdentitySection({ node, agent }: { node: Node; agent: AgentDefinitionHa
225
229
  * delete, is what says "built in, always there" without a sentence explaining it.
226
230
  */
227
231
  function ContactSection({ node }: { node: Node }) {
228
- const { i18n } = useIntelRouterContext();
232
+ const i18n = useI18n();
229
233
  const [copied, setCopied] = useState(false);
230
234
  // ⚠️ The runtime's own address, NOT intel's proxy. This one is dialled by a portal or another
231
235
  // agent with its own credential (ADR-0005 §7); intel's door is for this page's browser calls and
@@ -321,7 +325,7 @@ function KnowledgeSection({
321
325
  definition: AgentDefinition;
322
326
  agent: AgentDefinitionHandle;
323
327
  }) {
324
- const { i18n } = useIntelRouterContext();
328
+ const i18n = useI18n();
325
329
  const [adding, setAdding] = useState(false);
326
330
 
327
331
  function write(references: AgentReference[]) {
@@ -398,7 +402,7 @@ function ReferenceRow({
398
402
  setRole(role: AgentReferenceRole): void;
399
403
  remove(): void;
400
404
  }) {
401
- const { i18n } = useIntelRouterContext();
405
+ const i18n = useI18n();
402
406
  const { title, known } = useEntryTitle(reference.nodeId);
403
407
  return (
404
408
  <li className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
@@ -466,7 +470,8 @@ function ToolsSection({
466
470
  definition: AgentDefinition;
467
471
  agent: AgentDefinitionHandle;
468
472
  }) {
469
- const { data, i18n } = useIntelRouterContext();
473
+ const { data } = useIntelRouterContext();
474
+ const i18n = useI18n();
470
475
  const [adding, setAdding] = useState(false);
471
476
  const servers = useQuery({ queryKey: ["tool-servers"], queryFn: () => data.listToolServers() });
472
477
  const selected = definition.tools?.servers ?? [];
@@ -587,7 +592,7 @@ function ScheduleSection({
587
592
  definition: AgentDefinition;
588
593
  agent: AgentDefinitionHandle;
589
594
  }) {
590
- const { i18n } = useIntelRouterContext();
595
+ const i18n = useI18n();
591
596
  const [adding, setAdding] = useState(false);
592
597
 
593
598
  function write(schedules: AgentSchedule[]) {
@@ -601,10 +606,13 @@ function ScheduleSection({
601
606
  {definition.schedules.map((schedule, index) => (
602
607
  <li
603
608
  // biome-ignore lint/suspicious/noArrayIndexKey: same as the references above — an ordered array with no ids, edited by position
604
- key={`${schedule.cron}:${schedule.target.id}:${index}`}
609
+ key={`${schedule.cron}@${schedule.timezone}:${schedule.target.id}:${index}`}
605
610
  className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
606
611
  >
607
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>
608
616
  <EntryTitle entryId={schedule.target.id} flow={schedule.target.kind === "flow"} />
609
617
  <span className="text-xs text-muted-foreground">
610
618
  {i18n.t(`node.kind.${schedule.target.kind}`)}
@@ -645,8 +653,13 @@ function ScheduleSection({
645
653
  }
646
654
 
647
655
  function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSchedule): void }) {
648
- const { i18n } = useIntelRouterContext();
656
+ const i18n = useI18n();
649
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);
650
663
  const [target, setTarget] = useState<{ id: string; kind: "document" | "flow" } | null>(null);
651
664
 
652
665
  return (
@@ -655,7 +668,7 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
655
668
  className="space-y-4"
656
669
  onSubmit={(event) => {
657
670
  event.preventDefault();
658
- if (target && cron.trim()) add({ cron: cron.trim(), target });
671
+ if (target && cron.trim()) add({ cron: cron.trim(), timezone, target });
659
672
  }}
660
673
  >
661
674
  <label className="block text-sm font-medium">
@@ -674,6 +687,18 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
674
687
  {i18n.t("agent.cronHint")}
675
688
  </span>
676
689
  </label>
690
+ <label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
691
+ {i18n.t("agent.scheduleTimezone")}
692
+ </label>
693
+ <TimezoneCombobox
694
+ id="agent-schedule-timezone"
695
+ value={timezone}
696
+ onChange={setTimezone}
697
+ describedBy="agent-timezone-hint"
698
+ />
699
+ <span id="agent-timezone-hint" className="-mt-2 block text-xs text-muted-foreground">
700
+ {i18n.t("agent.scheduleTimezoneHint")}
701
+ </span>
677
702
  <EntryPicker
678
703
  kind="document"
679
704
  flows
@@ -705,7 +730,7 @@ function ModelSection({
705
730
  definition: AgentDefinition;
706
731
  agent: AgentDefinitionHandle;
707
732
  }) {
708
- const { i18n } = useIntelRouterContext();
733
+ const i18n = useI18n();
709
734
  const models = selectableModels(definition.model);
710
735
  const current = modelFacts(definition.model);
711
736
 
@@ -760,7 +785,7 @@ function ModelName({ facts }: { facts: ModelFacts }) {
760
785
  // The two numbers a model is actually chosen on. Absent for a model this installation has no figures
761
786
  // for — the row then simply ends, because a made-up zero would be a false statement about money.
762
787
  function ModelFigures({ facts }: { facts: ModelFacts }) {
763
- const { i18n } = useIntelRouterContext();
788
+ const i18n = useI18n();
764
789
  if (facts.contextTokens === null || facts.price === null) return null;
765
790
 
766
791
  return (
@@ -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";