@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.
- package/package.json +3 -2
- package/src/agent/agent-calendar/agent-calendar.tsx +32 -14
- package/src/agent/agent-chat/agent-chat.tsx +3 -1
- package/src/agent/agent-cron/agent-cron.ts +49 -113
- package/src/agent/agent-definition/agent-definition.ts +18 -9
- package/src/agent/agent-entry-title/agent-entry-title.ts +3 -1
- package/src/agent/agent-log/agent-log.tsx +6 -3
- package/src/agent/agent-profile/agent-profile.tsx +37 -12
- package/src/agent/agent.tsx +17 -9
- package/src/app/app-sidebar/app-sidebar.tsx +2 -2
- package/src/app/app-tree/app-tree.tsx +5 -3
- package/src/app/app.tsx +2 -2
- package/src/app/header-search/header-search.tsx +3 -1
- package/src/app/settings-dialog/settings-dialog.tsx +115 -0
- package/src/app/tree-move/tree-move.tsx +3 -1
- package/src/app/user-footer/user-footer.tsx +25 -2
- package/src/app/view-toggle/view-toggle.tsx +2 -2
- package/src/archive/archive.tsx +3 -1
- package/src/components/ui/popover.tsx +41 -0
- package/src/entry-picker/entry-picker.tsx +3 -1
- package/src/flow-runs/flow-runs.tsx +6 -3
- package/src/flows/flows.tsx +13 -9
- package/src/flows/node-palette/node-palette.tsx +2 -2
- package/src/folder-contents/folder-contents.tsx +3 -1
- package/src/graph-pane/graph-pane.tsx +2 -2
- package/src/i18n/de.json +406 -0
- package/src/i18n/en.json +20 -5
- package/src/i18n/es.json +406 -0
- package/src/i18n/i18n-context.tsx +65 -0
- package/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/src/i18n/i18n.ts +15 -11
- package/src/i18n/i18n.types.ts +43 -0
- package/src/main.tsx +33 -12
- package/src/modal/modal.tsx +2 -2
- package/src/node-table/node-table.tsx +3 -1
- package/src/nodes/nodes.tsx +7 -3
- package/src/resource-menu/resource-menu.tsx +8 -4
- package/src/router/router.types.ts +3 -2
- package/src/save-button/save-button.tsx +3 -3
- package/src/sign-in-refused/sign-in-refused.tsx +3 -2
- package/src/theme/theme-context.tsx +89 -0
- package/src/theme/theme.ts +51 -21
- package/src/timezone/timezone-combobox/timezone-combobox.tsx +149 -0
- package/src/timezone/timezone-context.tsx +45 -0
- package/src/timezone/timezone.ts +159 -0
- 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.
|
|
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.
|
|
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 {
|
|
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
|
-
* ⚠️
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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) => ({
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
*
|
|
119
|
-
*
|
|
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(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 —
|
|
77
|
-
*
|
|
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(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
788
|
+
const i18n = useI18n();
|
|
764
789
|
if (facts.contextTokens === null || facts.price === null) return null;
|
|
765
790
|
|
|
766
791
|
return (
|
package/src/agent/agent.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
?
|
|
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:
|
|
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
|
|
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
|
|
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";
|