@anchrd/intel-ui 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +46 -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 +133 -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/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 +402 -0
- package/src/i18n/en.json +16 -5
- package/src/i18n/es.json +402 -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-context.tsx +45 -0
- package/src/timezone/timezone.ts +60 -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.13.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 { availableTimezones } from "@/timezone/timezone.ts";
|
|
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,15 @@ 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);
|
|
663
|
+
const zones = availableTimezones();
|
|
664
|
+
const options = zones.includes(timezone) ? zones : [timezone, ...zones];
|
|
650
665
|
const [target, setTarget] = useState<{ id: string; kind: "document" | "flow" } | null>(null);
|
|
651
666
|
|
|
652
667
|
return (
|
|
@@ -655,7 +670,7 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
|
|
|
655
670
|
className="space-y-4"
|
|
656
671
|
onSubmit={(event) => {
|
|
657
672
|
event.preventDefault();
|
|
658
|
-
if (target && cron.trim()) add({ cron: cron.trim(), target });
|
|
673
|
+
if (target && cron.trim()) add({ cron: cron.trim(), timezone, target });
|
|
659
674
|
}}
|
|
660
675
|
>
|
|
661
676
|
<label className="block text-sm font-medium">
|
|
@@ -674,6 +689,25 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
|
|
|
674
689
|
{i18n.t("agent.cronHint")}
|
|
675
690
|
</span>
|
|
676
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>
|
|
677
711
|
<EntryPicker
|
|
678
712
|
kind="document"
|
|
679
713
|
flows
|
|
@@ -705,7 +739,7 @@ function ModelSection({
|
|
|
705
739
|
definition: AgentDefinition;
|
|
706
740
|
agent: AgentDefinitionHandle;
|
|
707
741
|
}) {
|
|
708
|
-
const
|
|
742
|
+
const i18n = useI18n();
|
|
709
743
|
const models = selectableModels(definition.model);
|
|
710
744
|
const current = modelFacts(definition.model);
|
|
711
745
|
|
|
@@ -760,7 +794,7 @@ function ModelName({ facts }: { facts: ModelFacts }) {
|
|
|
760
794
|
// The two numbers a model is actually chosen on. Absent for a model this installation has no figures
|
|
761
795
|
// for — the row then simply ends, because a made-up zero would be a false statement about money.
|
|
762
796
|
function ModelFigures({ facts }: { facts: ModelFacts }) {
|
|
763
|
-
const
|
|
797
|
+
const i18n = useI18n();
|
|
764
798
|
if (facts.contextTokens === null || facts.price === null) return null;
|
|
765
799
|
|
|
766
800
|
return (
|