@anchrd/intel-ui 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-models/agent-models.ts +142 -3
- package/src/agent/agent-models/agent-models.types.ts +18 -0
- package/src/agent/agent-profile/agent-profile.tsx +114 -31
- 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 +17 -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
|
@@ -10,7 +10,15 @@ import { Check, Copy, Info, KeyRound, MessageSquare, Plus, Trash2, Wrench } from
|
|
|
10
10
|
import { useState } from "react";
|
|
11
11
|
import { type AgentDefinitionHandle, asDraft } from "@/agent/agent-definition/agent-definition.ts";
|
|
12
12
|
import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
formatContextTokens,
|
|
15
|
+
formatPricePerMillion,
|
|
16
|
+
modelFacts,
|
|
17
|
+
modelKey,
|
|
18
|
+
parseModelKey,
|
|
19
|
+
selectableModels,
|
|
20
|
+
} from "@/agent/agent-models/agent-models.ts";
|
|
21
|
+
import type { ModelFacts } from "@/agent/agent-models/agent-models.types.ts";
|
|
14
22
|
import {
|
|
15
23
|
Select,
|
|
16
24
|
SelectContent,
|
|
@@ -21,8 +29,11 @@ import {
|
|
|
21
29
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
22
30
|
import { agentMcpAddress } from "@/data/agent-runtime/agent-runtime.ts";
|
|
23
31
|
import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
|
|
32
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
24
33
|
import { Modal } from "@/modal/modal.tsx";
|
|
25
34
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
35
|
+
import { availableTimezones } from "@/timezone/timezone.ts";
|
|
36
|
+
import { useTimezone } from "@/timezone/timezone-context.tsx";
|
|
26
37
|
|
|
27
38
|
// No dividers between sections — the space is the separation (#204). The explanation sits behind
|
|
28
39
|
// the icon rather than under the title, so the page shows what the agent IS and keeps the prose
|
|
@@ -82,7 +93,7 @@ function SectionHint({ hint }: { hint: string }) {
|
|
|
82
93
|
* versioned, shared and read into model context (ADR-0005 §4).
|
|
83
94
|
*/
|
|
84
95
|
export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
|
|
85
|
-
const
|
|
96
|
+
const i18n = useI18n();
|
|
86
97
|
const definition = agent.definition;
|
|
87
98
|
|
|
88
99
|
// The definition is `null` in exactly one window: the node exists and no version has been written
|
|
@@ -138,7 +149,8 @@ export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefiniti
|
|
|
138
149
|
* not while they are describing what the agent should do.
|
|
139
150
|
*/
|
|
140
151
|
function IdentitySection({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
|
|
141
|
-
const { data
|
|
152
|
+
const { data } = useIntelRouterContext();
|
|
153
|
+
const i18n = useI18n();
|
|
142
154
|
const queryClient = useQueryClient();
|
|
143
155
|
const [confirming, setConfirming] = useState(false);
|
|
144
156
|
const applicationId = agent.query.data?.applicationId ?? null;
|
|
@@ -217,7 +229,7 @@ function IdentitySection({ node, agent }: { node: Node; agent: AgentDefinitionHa
|
|
|
217
229
|
* delete, is what says "built in, always there" without a sentence explaining it.
|
|
218
230
|
*/
|
|
219
231
|
function ContactSection({ node }: { node: Node }) {
|
|
220
|
-
const
|
|
232
|
+
const i18n = useI18n();
|
|
221
233
|
const [copied, setCopied] = useState(false);
|
|
222
234
|
// ⚠️ The runtime's own address, NOT intel's proxy. This one is dialled by a portal or another
|
|
223
235
|
// agent with its own credential (ADR-0005 §7); intel's door is for this page's browser calls and
|
|
@@ -313,7 +325,7 @@ function KnowledgeSection({
|
|
|
313
325
|
definition: AgentDefinition;
|
|
314
326
|
agent: AgentDefinitionHandle;
|
|
315
327
|
}) {
|
|
316
|
-
const
|
|
328
|
+
const i18n = useI18n();
|
|
317
329
|
const [adding, setAdding] = useState(false);
|
|
318
330
|
|
|
319
331
|
function write(references: AgentReference[]) {
|
|
@@ -390,7 +402,7 @@ function ReferenceRow({
|
|
|
390
402
|
setRole(role: AgentReferenceRole): void;
|
|
391
403
|
remove(): void;
|
|
392
404
|
}) {
|
|
393
|
-
const
|
|
405
|
+
const i18n = useI18n();
|
|
394
406
|
const { title, known } = useEntryTitle(reference.nodeId);
|
|
395
407
|
return (
|
|
396
408
|
<li className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
|
@@ -458,7 +470,8 @@ function ToolsSection({
|
|
|
458
470
|
definition: AgentDefinition;
|
|
459
471
|
agent: AgentDefinitionHandle;
|
|
460
472
|
}) {
|
|
461
|
-
const { data
|
|
473
|
+
const { data } = useIntelRouterContext();
|
|
474
|
+
const i18n = useI18n();
|
|
462
475
|
const [adding, setAdding] = useState(false);
|
|
463
476
|
const servers = useQuery({ queryKey: ["tool-servers"], queryFn: () => data.listToolServers() });
|
|
464
477
|
const selected = definition.tools?.servers ?? [];
|
|
@@ -579,7 +592,7 @@ function ScheduleSection({
|
|
|
579
592
|
definition: AgentDefinition;
|
|
580
593
|
agent: AgentDefinitionHandle;
|
|
581
594
|
}) {
|
|
582
|
-
const
|
|
595
|
+
const i18n = useI18n();
|
|
583
596
|
const [adding, setAdding] = useState(false);
|
|
584
597
|
|
|
585
598
|
function write(schedules: AgentSchedule[]) {
|
|
@@ -593,10 +606,13 @@ function ScheduleSection({
|
|
|
593
606
|
{definition.schedules.map((schedule, index) => (
|
|
594
607
|
<li
|
|
595
608
|
// biome-ignore lint/suspicious/noArrayIndexKey: same as the references above — an ordered array with no ids, edited by position
|
|
596
|
-
key={`${schedule.cron}:${schedule.target.id}:${index}`}
|
|
609
|
+
key={`${schedule.cron}@${schedule.timezone}:${schedule.target.id}:${index}`}
|
|
597
610
|
className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
|
|
598
611
|
>
|
|
599
612
|
<code className="rounded bg-muted px-2 py-1 text-xs">{schedule.cron}</code>
|
|
613
|
+
{/* The zone belongs beside the expression: "0 8 * * *" alone does not say eight
|
|
614
|
+
o'clock WHERE, and that is the whole difference this ticket is about. */}
|
|
615
|
+
<span className="text-xs text-muted-foreground">{schedule.timezone}</span>
|
|
600
616
|
<EntryTitle entryId={schedule.target.id} flow={schedule.target.kind === "flow"} />
|
|
601
617
|
<span className="text-xs text-muted-foreground">
|
|
602
618
|
{i18n.t(`node.kind.${schedule.target.kind}`)}
|
|
@@ -637,8 +653,15 @@ function ScheduleSection({
|
|
|
637
653
|
}
|
|
638
654
|
|
|
639
655
|
function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSchedule): void }) {
|
|
640
|
-
const
|
|
656
|
+
const i18n = useI18n();
|
|
641
657
|
const [cron, setCron] = useState("0 8 * * *");
|
|
658
|
+
// ⚠️ The reader's preference is the SUGGESTION, and only at creation time (#228). What is stored
|
|
659
|
+
// is this value, so the schedule keeps meaning the same hour after its author travels — and it
|
|
660
|
+
// means the same hour to everybody else reading the agent.
|
|
661
|
+
const preferred = useTimezone();
|
|
662
|
+
const [timezone, setTimezone] = useState(preferred);
|
|
663
|
+
const zones = availableTimezones();
|
|
664
|
+
const options = zones.includes(timezone) ? zones : [timezone, ...zones];
|
|
642
665
|
const [target, setTarget] = useState<{ id: string; kind: "document" | "flow" } | null>(null);
|
|
643
666
|
|
|
644
667
|
return (
|
|
@@ -647,7 +670,7 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
|
|
|
647
670
|
className="space-y-4"
|
|
648
671
|
onSubmit={(event) => {
|
|
649
672
|
event.preventDefault();
|
|
650
|
-
if (target && cron.trim()) add({ cron: cron.trim(), target });
|
|
673
|
+
if (target && cron.trim()) add({ cron: cron.trim(), timezone, target });
|
|
651
674
|
}}
|
|
652
675
|
>
|
|
653
676
|
<label className="block text-sm font-medium">
|
|
@@ -666,6 +689,25 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
|
|
|
666
689
|
{i18n.t("agent.cronHint")}
|
|
667
690
|
</span>
|
|
668
691
|
</label>
|
|
692
|
+
<label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
|
|
693
|
+
{i18n.t("agent.scheduleTimezone")}
|
|
694
|
+
</label>
|
|
695
|
+
<select
|
|
696
|
+
id="agent-schedule-timezone"
|
|
697
|
+
value={timezone}
|
|
698
|
+
onChange={(event) => setTimezone(event.target.value)}
|
|
699
|
+
aria-describedby="agent-timezone-hint"
|
|
700
|
+
className="-mt-2 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
701
|
+
>
|
|
702
|
+
{options.map((zone) => (
|
|
703
|
+
<option key={zone} value={zone}>
|
|
704
|
+
{zone.replace(/_/g, " ")}
|
|
705
|
+
</option>
|
|
706
|
+
))}
|
|
707
|
+
</select>
|
|
708
|
+
<span id="agent-timezone-hint" className="-mt-2 block text-xs text-muted-foreground">
|
|
709
|
+
{i18n.t("agent.scheduleTimezoneHint")}
|
|
710
|
+
</span>
|
|
669
711
|
<EntryPicker
|
|
670
712
|
kind="document"
|
|
671
713
|
flows
|
|
@@ -697,30 +739,71 @@ function ModelSection({
|
|
|
697
739
|
definition: AgentDefinition;
|
|
698
740
|
agent: AgentDefinitionHandle;
|
|
699
741
|
}) {
|
|
700
|
-
const
|
|
742
|
+
const i18n = useI18n();
|
|
701
743
|
const models = selectableModels(definition.model);
|
|
744
|
+
const current = modelFacts(definition.model);
|
|
702
745
|
|
|
703
746
|
return (
|
|
704
747
|
<Section title={i18n.t("agent.model")} hint={i18n.t("agent.modelHint")}>
|
|
705
|
-
<
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
<
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
748
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
749
|
+
<Select
|
|
750
|
+
value={modelKey(definition.model)}
|
|
751
|
+
onValueChange={(key) => {
|
|
752
|
+
const model = parseModelKey(key);
|
|
753
|
+
if (model) agent.save.mutate({ ...asDraft(definition), model });
|
|
754
|
+
}}
|
|
755
|
+
>
|
|
756
|
+
<SelectTrigger aria-label={i18n.t("agent.model")} className="w-full max-w-md sm:w-72">
|
|
757
|
+
{/* ⚠️ Own children rather than the default mirror of the chosen item: the item carries
|
|
758
|
+
the figures too, and mirrored into the trigger they would stand twice in one row. */}
|
|
759
|
+
<SelectValue>
|
|
760
|
+
<ModelName facts={current} />
|
|
761
|
+
</SelectValue>
|
|
762
|
+
</SelectTrigger>
|
|
763
|
+
<SelectContent>
|
|
764
|
+
{models.map((model) => {
|
|
765
|
+
const facts = modelFacts(model);
|
|
766
|
+
return (
|
|
767
|
+
<SelectItem key={modelKey(model)} value={modelKey(model)}>
|
|
768
|
+
{/* ⚠️ The space is not formatting. The option's accessible name is its text run
|
|
769
|
+
together, so without it a screen reader reads "…70B Fast24k context". The eye
|
|
770
|
+
never notices, because the row is a flex box with a gap. */}
|
|
771
|
+
<ModelName facts={facts} /> <ModelFigures facts={facts} />
|
|
772
|
+
</SelectItem>
|
|
773
|
+
);
|
|
774
|
+
})}
|
|
775
|
+
</SelectContent>
|
|
776
|
+
</Select>
|
|
777
|
+
<ModelFigures facts={current} />
|
|
778
|
+
</div>
|
|
724
779
|
</Section>
|
|
725
780
|
);
|
|
726
781
|
}
|
|
782
|
+
|
|
783
|
+
// Brand first, then the model's own name — "Claude Sonnet 5", not "claude-sonnet-5 · anthropic". The
|
|
784
|
+
// provider stays in the definition; it names who serves the model, which is not what anybody reads a
|
|
785
|
+
// list of models for.
|
|
786
|
+
function ModelName({ facts }: { facts: ModelFacts }) {
|
|
787
|
+
return (
|
|
788
|
+
<span className="truncate">
|
|
789
|
+
<span className="font-medium">{facts.brand}</span> {facts.name}
|
|
790
|
+
</span>
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// The two numbers a model is actually chosen on. Absent for a model this installation has no figures
|
|
795
|
+
// for — the row then simply ends, because a made-up zero would be a false statement about money.
|
|
796
|
+
function ModelFigures({ facts }: { facts: ModelFacts }) {
|
|
797
|
+
const i18n = useI18n();
|
|
798
|
+
if (facts.contextTokens === null || facts.price === null) return null;
|
|
799
|
+
|
|
800
|
+
return (
|
|
801
|
+
<span className="text-xs whitespace-nowrap text-muted-foreground">
|
|
802
|
+
{i18n.t("agent.modelFigures", {
|
|
803
|
+
context: formatContextTokens(facts.contextTokens),
|
|
804
|
+
input: formatPricePerMillion(facts.price.inputPerMillion, i18n.locale),
|
|
805
|
+
output: formatPricePerMillion(facts.price.outputPerMillion, i18n.locale),
|
|
806
|
+
})}
|
|
807
|
+
</span>
|
|
808
|
+
);
|
|
809
|
+
}
|
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";
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
SidebarMenuItem,
|
|
14
14
|
useSidebar,
|
|
15
15
|
} from "@/components/ui/sidebar";
|
|
16
|
-
import {
|
|
16
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
17
17
|
|
|
18
18
|
// One tree is the whole navigation: documents and flows side by side in the folder they share, with
|
|
19
19
|
// no Intelligence or Flows entry above them (ADR-0004). Tools stay below, next to the user, because
|
|
@@ -25,7 +25,7 @@ export function AppSidebar({
|
|
|
25
25
|
width: number;
|
|
26
26
|
onWidthChange(width: number): void;
|
|
27
27
|
}) {
|
|
28
|
-
const
|
|
28
|
+
const i18n = useI18n();
|
|
29
29
|
// `offcanvas` rather than `icon`: the icon rail is the mode for a list of equal entries with one
|
|
30
30
|
// icon each, and a tree says what it says through indentation, disclosure arrows and nesting —
|
|
31
31
|
// none of which survives 48px. Collapsing exists to free the canvas, so the sidebar goes.
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
41
41
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
42
42
|
import { useCapabilities } from "@/hooks/use-capabilities.ts";
|
|
43
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
43
44
|
import { kindIcons } from "@/kind-icon.ts";
|
|
44
45
|
import { Modal } from "@/modal/modal.tsx";
|
|
45
46
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -106,7 +107,8 @@ function levelKey(level: Level): readonly unknown[] {
|
|
|
106
107
|
export const TreeEntryMediaType = "application/x-intel-tree-entry";
|
|
107
108
|
|
|
108
109
|
export function AppTree() {
|
|
109
|
-
const { data
|
|
110
|
+
const { data } = useIntelRouterContext();
|
|
111
|
+
const i18n = useI18n();
|
|
110
112
|
const queryClient = useQueryClient();
|
|
111
113
|
const navigate = useNavigate();
|
|
112
114
|
const [expanded, setExpanded] = useState<readonly Level[]>([]);
|
|
@@ -720,7 +722,7 @@ function AddMenu({
|
|
|
720
722
|
variant?: "icon" | "row";
|
|
721
723
|
onSelect(kind: NewKind | "upload" | ImportKind): void;
|
|
722
724
|
}) {
|
|
723
|
-
const
|
|
725
|
+
const i18n = useI18n();
|
|
724
726
|
// Whether this deployment runs an agent runtime (#190). Until the answer arrives the item is
|
|
725
727
|
// absent rather than provisional: a menu entry that vanished after being seen was a lie, one that
|
|
726
728
|
// appears a beat late was merely loading.
|
|
@@ -789,7 +791,7 @@ function CreateForm({
|
|
|
789
791
|
pending: boolean;
|
|
790
792
|
submit(title: string, columns: string[]): void;
|
|
791
793
|
}) {
|
|
792
|
-
const
|
|
794
|
+
const i18n = useI18n();
|
|
793
795
|
const [title, setTitle] = useState("");
|
|
794
796
|
const [columns, setColumns] = useState("");
|
|
795
797
|
const parsedColumns = columnsOf(columns);
|
package/src/app/app.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "@/components/ui/breadcrumb";
|
|
14
14
|
import { Separator } from "@/components/ui/separator";
|
|
15
15
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
|
16
|
-
import {
|
|
16
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
17
17
|
|
|
18
18
|
const sections = [
|
|
19
19
|
{ path: "/nodes", labelKey: "nav.nodes" },
|
|
@@ -22,7 +22,7 @@ const sections = [
|
|
|
22
22
|
] as const;
|
|
23
23
|
|
|
24
24
|
export function App() {
|
|
25
|
-
const
|
|
25
|
+
const i18n = useI18n();
|
|
26
26
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
27
27
|
const current = sections.find(
|
|
28
28
|
(section) => pathname === section.path || pathname.startsWith(`${section.path}/`),
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
DialogHeader,
|
|
19
19
|
DialogTitle,
|
|
20
20
|
} from "@/components/ui/dialog";
|
|
21
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
21
22
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
22
23
|
|
|
23
24
|
const Kinds = ["node", "flow", "tool"] as const;
|
|
@@ -50,7 +51,8 @@ function matches(query: string, ...fields: (string | null)[]): boolean {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
export function HeaderSearch() {
|
|
53
|
-
const { data
|
|
54
|
+
const { data } = useIntelRouterContext();
|
|
55
|
+
const i18n = useI18n();
|
|
54
56
|
const navigate = useNavigate();
|
|
55
57
|
const [open, setOpen] = useState(false);
|
|
56
58
|
const [text, setText] = useState("");
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Dialog,
|
|
3
|
+
DialogContent,
|
|
4
|
+
DialogDescription,
|
|
5
|
+
DialogHeader,
|
|
6
|
+
DialogTitle,
|
|
7
|
+
} from "@/components/ui/dialog";
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from "@/components/ui/select";
|
|
15
|
+
import { useI18n, useLanguage } from "@/i18n/i18n-context.tsx";
|
|
16
|
+
import { languageName } from "@/i18n/i18n-languages/i18n-languages.ts";
|
|
17
|
+
import { type ThemeChoice, ThemeChoices } from "@/theme/theme.ts";
|
|
18
|
+
import { useTheme } from "@/theme/theme-context.tsx";
|
|
19
|
+
import { availableTimezones, browserTimezone } from "@/timezone/timezone.ts";
|
|
20
|
+
import { useTimezoneSelection } from "@/timezone/timezone-context.tsx";
|
|
21
|
+
|
|
22
|
+
// The settings that belong to the person rather than to the installation: language, appearance and
|
|
23
|
+
// timezone. All three are client state — they live in localStorage, not in the Intel HTTP surface —
|
|
24
|
+
// so they do not belong on a screen with a save button. A choice applies at once; a dialog that
|
|
25
|
+
// previews and only commits on "Save" would carry three states too many for three dropdowns.
|
|
26
|
+
//
|
|
27
|
+
// The dialog is opened from outside (the user footer holds the state), because its trigger is a
|
|
28
|
+
// DropdownMenuItem: Radix unmounts the menu content when an item is chosen and would take a dialog
|
|
29
|
+
// nested inside it along in the same frame.
|
|
30
|
+
interface SettingsDialogProps {
|
|
31
|
+
open: boolean;
|
|
32
|
+
onOpenChange(open: boolean): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|
36
|
+
const i18n = useI18n();
|
|
37
|
+
const language = useLanguage();
|
|
38
|
+
const theme = useTheme();
|
|
39
|
+
const timezone = useTimezoneSelection();
|
|
40
|
+
|
|
41
|
+
// ⚠️ The active zone is put in front of the list unless it is already there, and that is not
|
|
42
|
+
// belt-and-braces: `Intl.supportedValuesOf("timeZone")` returns only CANONICAL IANA names, and
|
|
43
|
+
// "UTC" is not among them (it is an alias of Etc/UTC). A reader whose browser reports "UTC" —
|
|
44
|
+
// every CI machine, plenty of containers and servers — would otherwise get a Select whose value
|
|
45
|
+
// matches no item, and Radix renders that as an EMPTY field. Found by CI, not by the test.
|
|
46
|
+
//
|
|
47
|
+
// The same line covers the engine without `supportedValuesOf` at all: the list is empty and the
|
|
48
|
+
// reader still has their own zone to look at.
|
|
49
|
+
const zones = availableTimezones();
|
|
50
|
+
const current = timezone?.current ?? browserTimezone();
|
|
51
|
+
const options = zones.includes(current) ? zones : [current, ...zones];
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
55
|
+
<DialogContent className="sm:max-w-md">
|
|
56
|
+
<DialogHeader>
|
|
57
|
+
<DialogTitle>{i18n.t("settings.title")}</DialogTitle>
|
|
58
|
+
<DialogDescription>{i18n.t("settings.description")}</DialogDescription>
|
|
59
|
+
</DialogHeader>
|
|
60
|
+
<div className="grid gap-6">
|
|
61
|
+
{/* Without a provider there is nothing to choose — then the block is absent rather than a
|
|
62
|
+
dead dropdown. The number of languages is deliberately NOT checked: there are always
|
|
63
|
+
at least the three built-in ones, so a condition on it would be dead code. */}
|
|
64
|
+
{language ? (
|
|
65
|
+
<div className="grid gap-2">
|
|
66
|
+
<label className="text-sm font-medium" htmlFor="settings-language">
|
|
67
|
+
{i18n.t("settings.language")}
|
|
68
|
+
</label>
|
|
69
|
+
<Select value={language.current} onValueChange={language.select}>
|
|
70
|
+
<SelectTrigger id="settings-language" className="w-full">
|
|
71
|
+
<SelectValue />
|
|
72
|
+
</SelectTrigger>
|
|
73
|
+
<SelectContent>
|
|
74
|
+
{language.codes.map((code) => (
|
|
75
|
+
<SelectItem key={code} value={code}>
|
|
76
|
+
{languageName(code)}
|
|
77
|
+
</SelectItem>
|
|
78
|
+
))}
|
|
79
|
+
</SelectContent>
|
|
80
|
+
</Select>
|
|
81
|
+
</div>
|
|
82
|
+
) : null}
|
|
83
|
+
{theme ? (
|
|
84
|
+
<div className="grid gap-2">
|
|
85
|
+
<label className="text-sm font-medium" htmlFor="settings-appearance">
|
|
86
|
+
{i18n.t("settings.appearance")}
|
|
87
|
+
</label>
|
|
88
|
+
{/* Radix hands out a raw string; the values come from the same list that fills the
|
|
89
|
+
menu, so the assertion here is the whole check. */}
|
|
90
|
+
<Select
|
|
91
|
+
value={theme.current}
|
|
92
|
+
onValueChange={(value) => theme.select(value as ThemeChoice)}
|
|
93
|
+
>
|
|
94
|
+
<SelectTrigger id="settings-appearance" className="w-full">
|
|
95
|
+
<SelectValue />
|
|
96
|
+
</SelectTrigger>
|
|
97
|
+
<SelectContent>
|
|
98
|
+
{ThemeChoices.map((choice) => (
|
|
99
|
+
<SelectItem key={choice} value={choice}>
|
|
100
|
+
{i18n.t(`settings.appearance.${choice}`)}
|
|
101
|
+
</SelectItem>
|
|
102
|
+
))}
|
|
103
|
+
</SelectContent>
|
|
104
|
+
</Select>
|
|
105
|
+
</div>
|
|
106
|
+
) : null}
|
|
107
|
+
{timezone ? (
|
|
108
|
+
<div className="grid gap-2">
|
|
109
|
+
<label className="text-sm font-medium" htmlFor="settings-timezone">
|
|
110
|
+
{i18n.t("settings.timezone")}
|
|
111
|
+
</label>
|
|
112
|
+
<Select value={timezone.current} onValueChange={timezone.select}>
|
|
113
|
+
<SelectTrigger id="settings-timezone" className="w-full">
|
|
114
|
+
<SelectValue />
|
|
115
|
+
</SelectTrigger>
|
|
116
|
+
{/* Several hundred zones: the list scrolls, and Radix keeps the selected one in
|
|
117
|
+
view when it opens. */}
|
|
118
|
+
<SelectContent className="max-h-72">
|
|
119
|
+
{options.map((zone) => (
|
|
120
|
+
<SelectItem key={zone} value={zone}>
|
|
121
|
+
{zone.replace(/_/g, " ")}
|
|
122
|
+
</SelectItem>
|
|
123
|
+
))}
|
|
124
|
+
</SelectContent>
|
|
125
|
+
</Select>
|
|
126
|
+
<p className="text-xs text-muted-foreground">{i18n.t("settings.timezoneHint")}</p>
|
|
127
|
+
</div>
|
|
128
|
+
) : null}
|
|
129
|
+
</div>
|
|
130
|
+
</DialogContent>
|
|
131
|
+
</Dialog>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -3,6 +3,7 @@ import { ChevronLeft, Folder } from "lucide-react";
|
|
|
3
3
|
import type * as React from "react";
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
6
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
6
7
|
import { Modal } from "@/modal/modal.tsx";
|
|
7
8
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
8
9
|
|
|
@@ -106,7 +107,8 @@ export function MoveDialog({
|
|
|
106
107
|
close(): void;
|
|
107
108
|
submit(destination: MoveDestination): void;
|
|
108
109
|
}) {
|
|
109
|
-
const { data
|
|
110
|
+
const { data } = useIntelRouterContext();
|
|
111
|
+
const i18n = useI18n();
|
|
110
112
|
const [destination, setDestination] = useState<MoveDestination | null>(initial);
|
|
111
113
|
// The browsed path, root first. It is also the descendant guard for the keyboard route: the row
|
|
112
114
|
// being moved is never offered, so its subtree can never be entered in the first place.
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
2
2
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Archive as ArchiveIcon,
|
|
5
|
+
ChevronsUpDown,
|
|
6
|
+
LogOut,
|
|
7
|
+
Settings,
|
|
8
|
+
User,
|
|
9
|
+
Wrench,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { useState } from "react";
|
|
4
12
|
import {
|
|
5
13
|
DropdownMenu,
|
|
6
14
|
DropdownMenuContent,
|
|
@@ -10,7 +18,9 @@ import {
|
|
|
10
18
|
} from "@/components/ui/dropdown-menu";
|
|
11
19
|
import { SidebarMenu, SidebarMenuItem } from "@/components/ui/sidebar";
|
|
12
20
|
import { loginPath } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
21
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
13
22
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
23
|
+
import { SettingsDialog } from "../settings-dialog/settings-dialog.tsx";
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
26
|
* Everything that belongs to the person rather than to the content, behind the person (#24).
|
|
@@ -24,9 +34,13 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
|
24
34
|
* what it shows depends on who is asking.
|
|
25
35
|
*/
|
|
26
36
|
export function UserFooter() {
|
|
27
|
-
const { data
|
|
37
|
+
const { data } = useIntelRouterContext();
|
|
38
|
+
const i18n = useI18n();
|
|
28
39
|
const navigate = useNavigate();
|
|
29
40
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
41
|
+
// The state lives here, not in the dialog: its trigger is a DropdownMenuItem, and Radix unmounts
|
|
42
|
+
// the menu content when an item is chosen — a dialog nested in there would go with it.
|
|
43
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
30
44
|
const session = useQuery({ queryKey: ["session"], queryFn: () => data.getSession() });
|
|
31
45
|
const logout = useMutation({
|
|
32
46
|
mutationFn: () => data.logout(),
|
|
@@ -83,6 +97,13 @@ export function UserFooter() {
|
|
|
83
97
|
{i18n.t("archive.title")}
|
|
84
98
|
</DropdownMenuItem>
|
|
85
99
|
<DropdownMenuSeparator />
|
|
100
|
+
{/* Language, appearance and timezone belong to the person, like everything else behind
|
|
101
|
+
this menu — and unlike the two rows above, they change nothing anybody else sees. */}
|
|
102
|
+
<DropdownMenuItem onSelect={() => setSettingsOpen(true)}>
|
|
103
|
+
<Settings aria-hidden="true" />
|
|
104
|
+
{i18n.t("settings.title")}
|
|
105
|
+
</DropdownMenuItem>
|
|
106
|
+
<DropdownMenuSeparator />
|
|
86
107
|
<DropdownMenuItem
|
|
87
108
|
disabled={logout.isPending}
|
|
88
109
|
onSelect={() => logout.mutate()}
|
|
@@ -93,6 +114,8 @@ export function UserFooter() {
|
|
|
93
114
|
</DropdownMenuItem>
|
|
94
115
|
</DropdownMenuContent>
|
|
95
116
|
</DropdownMenu>
|
|
117
|
+
{/* ⚠️ Outside the DropdownMenu on purpose — see the state above. */}
|
|
118
|
+
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
|
96
119
|
</SidebarMenuItem>
|
|
97
120
|
{/* ⚠️ Outside the menu on purpose. Choosing sign-out closes the popup, so a refusal rendered
|
|
98
121
|
inside it would be gone in the same frame it was written — the one message that must
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
2
2
|
import { History, Network, PencilRuler } from "lucide-react";
|
|
3
3
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
4
|
-
import {
|
|
4
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
5
5
|
import { type IntelView, viewFrom } from "@/router/selection-search.ts";
|
|
6
6
|
|
|
7
7
|
const icons = { editor: PencilRuler, graph: Network, runs: History } as const;
|
|
@@ -34,7 +34,7 @@ const labels = {
|
|
|
34
34
|
* are — every destination stays one click, and every button keeps one name.
|
|
35
35
|
*/
|
|
36
36
|
export function ViewToggle({ views = ["editor", "graph"] }: { views?: readonly IntelView[] }) {
|
|
37
|
-
const
|
|
37
|
+
const i18n = useI18n();
|
|
38
38
|
const navigate = useNavigate();
|
|
39
39
|
const state = useRouterState({
|
|
40
40
|
select: (current) => ({
|