@anchrd/intel-ui 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +2 -9
  2. package/src/app/app-tree/app-tree.tsx +59 -147
  3. package/src/app/settings-dialog/settings-dialog.tsx +10 -32
  4. package/src/data/intel-data-provider/intel-data-provider.ts +0 -163
  5. package/src/data/intel-data-provider/intel-data-provider.types.ts +0 -100
  6. package/src/entry-picker/entry-picker.tsx +6 -6
  7. package/src/i18n/de.json +6 -217
  8. package/src/i18n/en.json +6 -217
  9. package/src/i18n/es.json +6 -217
  10. package/src/import-bundle/import-bundle.tsx +152 -0
  11. package/src/kind-icon.ts +1 -15
  12. package/src/main.tsx +7 -10
  13. package/src/node-table/node-table.tsx +2 -2
  14. package/src/nodes/nodes.tsx +0 -34
  15. package/src/resource-menu/resource-menu.tsx +42 -14
  16. package/src/section-hint/section-hint.tsx +2 -2
  17. package/src/title-row/title-row.tsx +5 -22
  18. package/src/tools/tools.tsx +0 -2
  19. package/vite.config.ts +0 -4
  20. package/src/agent/agent-avatar/agent-avatar.tsx +0 -34
  21. package/src/agent/agent-calendar/agent-calendar.tsx +0 -160
  22. package/src/agent/agent-chat/agent-chat.tsx +0 -118
  23. package/src/agent/agent-costs/agent-costs.ts +0 -95
  24. package/src/agent/agent-cron/agent-cron.ts +0 -68
  25. package/src/agent/agent-definition/agent-definition.ts +0 -96
  26. package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +0 -48
  27. package/src/agent/agent-entry-title/agent-entry-title.ts +0 -72
  28. package/src/agent/agent-log/agent-log.tsx +0 -308
  29. package/src/agent/agent-models/agent-models.ts +0 -166
  30. package/src/agent/agent-models/agent-models.types.ts +0 -24
  31. package/src/agent/agent-profile/agent-profile.tsx +0 -1006
  32. package/src/agent/agent-state/agent-state.ts +0 -85
  33. package/src/agent/agent-status-dot/agent-status-dot.ts +0 -73
  34. package/src/agent/agent.tsx +0 -507
  35. package/src/board/board-calendar/board-calendar.tsx +0 -89
  36. package/src/board/board-card/board-card.tsx +0 -106
  37. package/src/board/board-data/board-data.ts +0 -241
  38. package/src/board/board-data/board-data.types.ts +0 -63
  39. package/src/board/board-detail/board-detail.tsx +0 -629
  40. package/src/board/board-gantt/board-gantt.ts +0 -545
  41. package/src/board/board-gantt/board-gantt.tsx +0 -286
  42. package/src/board/board-graph/board-graph.ts +0 -174
  43. package/src/board/board-graph/board-graph.tsx +0 -168
  44. package/src/board/board-items/board-items.ts +0 -183
  45. package/src/board/board-kanban/board-kanban.ts +0 -137
  46. package/src/board/board-kanban/board-kanban.tsx +0 -257
  47. package/src/board/board-status/board-status.ts +0 -59
  48. package/src/board/board-statuses/board-statuses.ts +0 -63
  49. package/src/board/board-statuses/board-statuses.tsx +0 -228
  50. package/src/board/board-table/board-table.ts +0 -33
  51. package/src/board/board-table/board-table.tsx +0 -413
  52. package/src/board/board-views/board-views.tsx +0 -68
  53. package/src/board/board-views/board-views.types.ts +0 -29
  54. package/src/board/board.tsx +0 -251
  55. package/src/components/ui/item-calendar.tsx +0 -181
  56. package/src/components/ui/item-gantt.tsx +0 -463
  57. package/src/components/ui/kanban.tsx +0 -282
  58. package/src/data/agent-runtime/agent-runtime.ts +0 -131
  59. package/src/hooks/use-capabilities.ts +0 -22
  60. package/src/hooks/use-model-catalog.ts +0 -26
  61. package/src/timezone/timezone-combobox/timezone-combobox.tsx +0 -149
  62. package/src/timezone/timezone-context.tsx +0 -65
  63. package/src/timezone/timezone.ts +0 -174
@@ -1,282 +0,0 @@
1
- import {
2
- type Announcements,
3
- closestCorners,
4
- DndContext,
5
- type DraggableAttributes,
6
- DragOverlay,
7
- type DragStartEvent,
8
- KeyboardSensor,
9
- PointerSensor,
10
- useDroppable,
11
- useSensor,
12
- useSensors,
13
- } from "@dnd-kit/core";
14
- import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
15
- import { CSS } from "@dnd-kit/utilities";
16
- import { GripVertical } from "lucide-react";
17
- import { type ReactNode, useState } from "react";
18
- import { createPortal } from "react-dom";
19
- import { cn } from "@/lib/utils";
20
-
21
- /**
22
- * A kanban board on dnd-kit, from the Kibo UI registry (MIT) and changed where it had to be.
23
- *
24
- * Registry code is our code, and four things about the original do not survive contact with a board
25
- * whose truth is on a server:
26
- *
27
- * ⚠️ **It reported a drop as a new ARRAY, and it reported one twice.** The original called
28
- * `onDataChange` in `onDragOver` for a cross-column move and again in `onDragEnd`, and both times
29
- * with a reordered copy of the whole list. Against a server that is two writes for one drag — and
30
- * the second one describes a state the first already changed. This version says nothing while the
31
- * card is in the air and emits ONE drop at the end, naming what was dropped and what it was dropped
32
- * on. What that means is the caller's to decide (`board-kanban.ts`), which is also where the single
33
- * `board_task_move` is sent from.
34
- *
35
- * ⚠️ **It mutated its own props** — `newData[activeIndex].column = overColumn` writes into the
36
- * object the caller passed in. With TanStack Query holding that object, a drag edited the cache in
37
- * place and no re-render followed.
38
- *
39
- * ⚠️ **`tunnel-rat` is gone.** It existed to render the dragged card into the overlay from inside
40
- * the card component; `DragOverlay` already does that, and the caller draws the overlay itself.
41
- * One dependency fewer for something React has a portal for.
42
- *
43
- * ⚠️ **The screen reader announcements are handed in.** The originals are English string literals
44
- * in the component; Intel speaks three languages and its text comes from `useI18n()`.
45
- *
46
- * ⚠️ **The card is not itself a `role="button"`, and it holds exactly ONE tab stop.** The original
47
- * spread `useSortable().attributes` onto the card, which contributes `role="button"` and
48
- * `tabIndex={0}` — and a card that also has to be OPENED then holds a real button inside that one:
49
- * two tab stops computing the same accessible name from the same contents, and an Enter that means
50
- * "start dragging" outside and "open this" inside (anchrd/intel#286). This version keeps the
51
- * `listeners` on the card so a pointer still drags it from anywhere, and hands the ACTIVATOR to the
52
- * caller's own control instead of minting a second button beside it (anchrd/intel#315). What
53
- * separates the two meanings is then the key, not the element: see `KanbanProvider`.
54
- */
55
-
56
- export interface KanbanColumn {
57
- id: string;
58
- name: string;
59
- }
60
-
61
- export interface KanbanDrop {
62
- activeId: string;
63
- // The column or the card it was let go over. `null` means it was let go over nothing — a drag
64
- // that ended outside the board, which is a cancel and not a move.
65
- overId: string | null;
66
- }
67
-
68
- export function KanbanBoard({
69
- id,
70
- droppable = true,
71
- children,
72
- className,
73
- }: {
74
- id: string;
75
- // A column that exists to be READ but must not be written into. It still draws and still holds
76
- // cards; it simply never becomes a drop target, so nothing can be dropped where the answer would
77
- // be a refusal (`board-kanban.tsx`).
78
- droppable?: boolean;
79
- children: ReactNode;
80
- className?: string;
81
- }) {
82
- const { isOver, setNodeRef } = useDroppable({ id, disabled: !droppable });
83
- return (
84
- <div
85
- ref={setNodeRef}
86
- data-slot="kanban-column"
87
- data-droppable={droppable}
88
- className={cn(
89
- "flex size-full min-h-40 flex-col divide-y overflow-hidden rounded-lg border bg-muted/40 text-xs ring-2 transition-colors",
90
- isOver ? "ring-ring" : "ring-transparent",
91
- className,
92
- )}
93
- >
94
- {children}
95
- </div>
96
- );
97
- }
98
-
99
- export function KanbanHeader({ children }: { children: ReactNode }) {
100
- return <div className="flex items-center gap-2 p-2 text-sm font-semibold">{children}</div>;
101
- }
102
-
103
- export function KanbanCards({ id, children }: { id: string; children: ReactNode }) {
104
- return (
105
- <div
106
- // The column scrolls, not the page: four lanes of different lengths must not make the board
107
- // as tall as its longest one.
108
- className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2"
109
- data-slot="kanban-cards"
110
- data-column={id}
111
- >
112
- {children}
113
- </div>
114
- );
115
- }
116
-
117
- /**
118
- * What a card hands to the one control inside it: the activator ref and dnd-kit's ARIA attributes.
119
- *
120
- * ⚠️ It is spread onto a control the CALLER renders, which is the whole point — the alternative is
121
- * a handle of the primitive's own beside it, and that is the second tab stop anchrd/intel#315 is
122
- * about. `role` and `tabIndex` are in here and are what a native `<button>` already is; what is not
123
- * already there is `aria-roledescription` and the `aria-describedby` pointing at dnd-kit's keyboard
124
- * instructions, and those are the half a reader needs to be told the control can also be picked up.
125
- */
126
- export type KanbanCardControl = DraggableAttributes & {
127
- ref: (node: HTMLElement | null) => void;
128
- };
129
-
130
- export function KanbanCard({
131
- id,
132
- roleDescription,
133
- children,
134
- className,
135
- }: {
136
- id: string;
137
- // What a reader is told this card IS, past its accessible name — dnd-kit's `aria-roledescription`,
138
- // whose own default is the English literal "draggable". Handed in because text comes from
139
- // `useI18n()` and only the caller has it.
140
- roleDescription: string;
141
- // A function, not a node: the one control in a card has to receive `control`, and a primitive that
142
- // rendered its own control instead would be back to two tab stops.
143
- children: (control: KanbanCardControl) => ReactNode;
144
- className?: string;
145
- }) {
146
- const {
147
- attributes,
148
- listeners,
149
- setNodeRef,
150
- setActivatorNodeRef,
151
- transition,
152
- transform,
153
- isDragging,
154
- } = useSortable({ id, attributes: { roleDescription } });
155
- return (
156
- <div
157
- ref={setNodeRef}
158
- style={{ transition, transform: CSS.Transform.toString(transform) }}
159
- // ⚠️ `listeners` here, `attributes` NOT. The pointer sensor has to see a press anywhere on the
160
- // card — a kanban whose cards can only be dragged by a grip is a kanban nobody drags — but
161
- // `attributes` carries `role="button"` and `tabIndex`, and those on the card would wrap the
162
- // control that OPENS it in a second button.
163
- //
164
- // ⚠️ Handing the activator down only holds because something claims to BE it. dnd-kit's
165
- // `KeyboardSensor` opens with `if (activator && event.target !== activator) return false` — a
166
- // guard that does not exist until `setActivatorNodeRef` has been given a node. Unclaimed, the
167
- // keyboard sensor accepts a key press from anywhere under these listeners, which is how a card
168
- // ended up impossible to open by keyboard (anchrd/intel#286). The pointer drag is unaffected
169
- // either way: `PointerSensor` never consults the activator node.
170
- {...listeners}
171
- data-slot="kanban-card"
172
- className={cn(
173
- "flex cursor-grab items-start gap-1 rounded-lg border bg-card p-3 text-left shadow-sm",
174
- // Left where it was, faded, rather than removed: a lane that reflows while a card is over
175
- // it moves the drop target out from under the pointer.
176
- isDragging && "opacity-40",
177
- className,
178
- )}
179
- >
180
- {/* Decoration, and deliberately not a control: it says "this can be taken hold of" to whoever
181
- uses a pointer, and a pointer can take hold of the whole card anyway. As a button it was a
182
- tab stop of its own on every card in the lane. */}
183
- <GripVertical aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
184
- <div className="min-w-0 flex-1">{children({ ...attributes, ref: setActivatorNodeRef })}</div>
185
- </div>
186
- );
187
- }
188
-
189
- /**
190
- * ⚠️ Space picks a card up, Enter does not — and that is the whole of anchrd/intel#315.
191
- *
192
- * dnd-kit's default is `start: ['Space', 'Enter']`, which forces the activator to be an element of
193
- * its own: a control that is also the card's "open" button cannot answer both keys with two
194
- * different things. With the start key narrowed to Space, the two meanings are told apart by the
195
- * KEY instead of by the element, and a card costs one tab stop rather than two — fifty cards, fifty
196
- * stops instead of a hundred.
197
- *
198
- * ⚠️ `start` is the ONLY list that is narrowed. `end` and `cancel` are dnd-kit's defaults spelled
199
- * out — Space, Enter and **Tab** put a card down, Escape leaves it where it was — and dropping one
200
- * of them here is not a smaller change than it looks. Tab is neither an end key nor a cancel key
201
- * once it is missing from this list, and `defaultKeyboardCoordinateGetter` answers nothing for it,
202
- * so nothing calls `preventDefault()`: the focus walks on to the next card while the first one is
203
- * still in the air and still following the arrow keys.
204
- *
205
- * Enter stays an END key, and it cannot open the card while ending a move: `handleEnd` calls
206
- * `preventDefault()` before the browser turns the key press into a click. Somebody holding a card
207
- * who presses the key they associate with "yes" should put it down, not be told nothing happened.
208
- */
209
- const kanbanKeyboardCodes = {
210
- start: ["Space"],
211
- cancel: ["Escape"],
212
- end: ["Space", "Enter", "Tab"],
213
- };
214
-
215
- export function KanbanProvider({
216
- columns,
217
- itemsByColumn,
218
- children,
219
- announcements,
220
- instructions,
221
- overlay,
222
- onDrop,
223
- className,
224
- }: {
225
- columns: KanbanColumn[];
226
- // Which cards stand in which column, in the order they are drawn. The provider needs it for the
227
- // sortable contexts and asks for it rather than deriving it, so the caller stays the only place
228
- // that knows how a board is ordered.
229
- itemsByColumn: Map<string, string[]>;
230
- children: (column: KanbanColumn) => ReactNode;
231
- announcements: Announcements;
232
- // ⚠️ Not optional, and not a nicety. dnd-kit's built-in text is an English literal naming the
233
- // default keys, and this board answers different ones. A card whose `aria-describedby` says
234
- // "press space or enter" while Enter opens it describes a widget that is not there.
235
- instructions: string;
236
- overlay(activeId: string): ReactNode;
237
- onDrop(drop: KanbanDrop): void;
238
- className?: string;
239
- }) {
240
- const [activeId, setActiveId] = useState<string | null>(null);
241
- const sensors = useSensors(
242
- // ⚠️ A distance before a drag starts, and it is not decoration: without it every click on a
243
- // card is a drag of zero pixels, and the card can then no longer be opened by clicking it.
244
- useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
245
- useSensor(KeyboardSensor, { keyboardCodes: kanbanKeyboardCodes }),
246
- );
247
-
248
- return (
249
- <DndContext
250
- accessibility={{ announcements, screenReaderInstructions: { draggable: instructions } }}
251
- // Corners rather than centres: a tall card over a short one wins on centre distance even
252
- // when the pointer is nowhere near it.
253
- collisionDetection={closestCorners}
254
- sensors={sensors}
255
- onDragStart={(event: DragStartEvent) => setActiveId(String(event.active.id))}
256
- onDragCancel={() => setActiveId(null)}
257
- onDragEnd={(event) => {
258
- setActiveId(null);
259
- const over = event.over === null ? null : String(event.over.id);
260
- onDrop({ activeId: String(event.active.id), overId: over });
261
- }}
262
- >
263
- <div className={cn("grid size-full auto-cols-fr grid-flow-col gap-3", className)}>
264
- {columns.map((column) => (
265
- <SortableContext
266
- key={column.id}
267
- items={itemsByColumn.get(column.id) ?? []}
268
- strategy={verticalListSortingStrategy}
269
- >
270
- {children(column)}
271
- </SortableContext>
272
- ))}
273
- </div>
274
- {typeof document === "undefined"
275
- ? null
276
- : createPortal(
277
- <DragOverlay>{activeId === null ? null : overlay(activeId)}</DragOverlay>,
278
- document.body,
279
- )}
280
- </DndContext>
281
- );
282
- }
@@ -1,131 +0,0 @@
1
- import { z } from "zod";
2
-
3
- /**
4
- * What the agent runtime answers, read back defensively.
5
- *
6
- * ⚠️ These shapes are NOT in `@anchrd/intel-contract`, and that is deliberate rather than an
7
- * oversight: the runtime (`packages/agent`) imports nothing from the contract either, so a run
8
- * record has no shared declaration anywhere. It crosses a network boundary into this package, so it
9
- * is parsed here — tolerantly, in the same spirit the runtime reads a definition, because a field
10
- * the runtime adds must not blank the log screen.
11
- */
12
- export const AgentRunTarget = z.object({
13
- kind: z.enum(["chat", "document", "flow", "mail", "task"]),
14
- id: z.string().nullable(),
15
- });
16
- export type AgentRunTarget = z.infer<typeof AgentRunTarget>;
17
-
18
- /**
19
- * What one run consumed at the provider, as the runtime recorded it (#250).
20
- *
21
- * ⚠️ `inputTokens` is the part that was NOT cached, not the size of the prompt — the prompt is all
22
- * four fields added together. Anything that shows the first as "the prompt" will report a run that
23
- * suddenly shrank to a twentieth of the previous one.
24
- *
25
- * ⚠️ Optional here, and the optionality is the feature. A run recorded before #250 carries no
26
- * numbers at all, and that is a different fact from "used no tokens" — the row shows an empty state
27
- * rather than four zeroes, because a zero reads as "cost nothing".
28
- */
29
- export const AgentUsage = z.object({
30
- inputTokens: z.number(),
31
- outputTokens: z.number(),
32
- cacheCreationInputTokens: z.number(),
33
- cacheReadInputTokens: z.number(),
34
- });
35
- export type AgentUsage = z.infer<typeof AgentUsage>;
36
-
37
- export const AgentRun = z.object({
38
- id: z.string(),
39
- trigger: z.enum(["chat", "schedule", "mail", "task", "manual"]),
40
- target: AgentRunTarget,
41
- status: z.enum(["running", "completed", "failed"]),
42
- startedAt: z.string(),
43
- finishedAt: z.string().nullable(),
44
- steps: z.number(),
45
- error: z.string().nullable(),
46
- /**
47
- * What the run answered, for the runs nobody was watching: `schedule`, `manual` and `task`.
48
- *
49
- * ⚠️ A `chat` or `mail` run carries none, and that is a runtime invariant rather than an accident
50
- * (#268): that answer belongs to the person whose window it was streamed into or to the
51
- * correspondent it was posted to. The log screen therefore claims nothing at all on those two
52
- * triggers — an empty state there would read as "the answer went missing".
53
- *
54
- * ⚠️ `.nullish()` for the same reason as `usage` below: a runtime from before #268 sends no such
55
- * field, and a required one would blank the whole Log tab over its absence.
56
- */
57
- answer: z.string().nullish(),
58
- // ⚠️ `.nullish()` on both, not `.nullable()`. The runtime is a separate deployment and may be a
59
- // version BEHIND this page: a run list from a runtime without #250/#251 has no such field at all,
60
- // and a required one would blank the whole Log tab over a missing number.
61
- usage: AgentUsage.nullish(),
62
- // Which model did the thinking, as `provider:model`, written into the run rather than looked up:
63
- // switching the model in the profile must not relabel every past run (#251).
64
- model: z.string().nullish(),
65
- });
66
- export type AgentRun = z.infer<typeof AgentRun>;
67
-
68
- /** Whether the agent is switched off. Runtime state, never part of the definition (#179). */
69
- export const AgentState = z.object({
70
- paused: z.boolean(),
71
- pausedAt: z.string().nullable(),
72
- alarmAt: z.number().nullable(),
73
- });
74
- export type AgentState = z.infer<typeof AgentState>;
75
-
76
- /** What "run now" answers: the run exists, and it is on its way. */
77
- export const AgentManualRun = z.object({
78
- runId: z.string(),
79
- status: z.string(),
80
- });
81
- export type AgentManualRun = z.infer<typeof AgentManualRun>;
82
-
83
- export const AgentLogEntry = z.object({
84
- id: z.string(),
85
- runId: z.string(),
86
- at: z.string(),
87
- level: z.enum(["info", "warn", "error"]),
88
- event: z.string(),
89
- detail: z.string(),
90
- });
91
- export type AgentLogEntry = z.infer<typeof AgentLogEntry>;
92
-
93
- export const AgentRunList = z.object({ items: z.array(AgentRun) });
94
- export type AgentRunList = z.infer<typeof AgentRunList>;
95
-
96
- export const AgentRunDetail = z.object({ run: AgentRun, log: z.array(AgentLogEntry) });
97
- export type AgentRunDetail = z.infer<typeof AgentRunDetail>;
98
-
99
- /**
100
- * The runtime's own address, for the one thing that is dialled from outside this page.
101
- *
102
- * ⚠️ Only the agent's MCP endpoint is reached here, and only as a string somebody copies. A portal
103
- * or another agent dials it directly with its own credential (ADR-0005 §7), so it has to be the
104
- * runtime's real hostname — a deployment that puts the runtime somewhere else sets
105
- * `VITE_AGENT_RUNTIME_URL` at build time. Empty means "the origin this page came from".
106
- *
107
- * ⚠️ NOT the browser's path any more (#178). Everything this page fetches goes through intel, which
108
- * proxies it to the runtime over a service binding; see `agentRuntimePath` below.
109
- */
110
- export function agentRuntimeBaseUrl(): string {
111
- return (import.meta.env.VITE_AGENT_RUNTIME_URL ?? "").replace(/\/$/, "");
112
- }
113
-
114
- export function agentMcpAddress(agentId: string): string {
115
- return `${agentRuntimeBaseUrl()}/agents/${encodeURIComponent(agentId)}/mcp`;
116
- }
117
-
118
- /**
119
- * Where the browser asks, which is intel and nothing else.
120
- *
121
- * ⚠️ One door, one origin, one credential: the page carries its intel session cookie, intel opens
122
- * it, and intel forwards the call to the runtime with the Gate token of the person who asked. That
123
- * is why there is no CORS here, no second hostname to configure, and above all no Gate token in
124
- * browser JavaScript — the runtime still demands `agents/run`, it just hears it from intel.
125
- *
126
- * The path is relative on purpose. It is joined to intel's `/api/v1` by the data provider, which is
127
- * the only module that knows where intel is.
128
- */
129
- export function agentRuntimePath(agentId: string, route: string): string {
130
- return `/agents/${encodeURIComponent(agentId)}${route}`;
131
- }
@@ -1,22 +0,0 @@
1
- import type { IntelCapabilities } from "@anchrd/intel-contract";
2
- import { type UseQueryResult, useQuery } from "@tanstack/react-query";
3
- import { useIntelRouterContext } from "@/router/router-context.ts";
4
-
5
- /**
6
- * What this installation is equipped to do, asked once and believed for the session (#190).
7
- *
8
- * ⚠️ Deployment facts, not the caller's permissions — a runtime does not appear or vanish while a
9
- * page is open, so the answer never goes stale and is never refetched. While the answer is still on
10
- * its way, `data` is `undefined`: a caller deciding whether to OFFER something treats that as "not
11
- * yet" (a menu item appearing late is fine; one that vanishes was a lie), and a caller deciding
12
- * whether to take a rendered page AWAY does the opposite.
13
- */
14
- export function useCapabilities(): UseQueryResult<IntelCapabilities> {
15
- const { data } = useIntelRouterContext();
16
- return useQuery({
17
- queryKey: ["capabilities"],
18
- queryFn: () => data.getCapabilities(),
19
- staleTime: Number.POSITIVE_INFINITY,
20
- retry: false,
21
- });
22
- }
@@ -1,26 +0,0 @@
1
- import type { ModelCatalog } from "@anchrd/intel-contract";
2
- import { type UseQueryResult, useQuery } from "@tanstack/react-query";
3
- import { useIntelRouterContext } from "@/router/router-context.ts";
4
-
5
- /**
6
- * What the offered models cost and how much they hold, asked of intel once an hour at most (#257).
7
- *
8
- * ⚠️ The caching is the point of the hook and not an optimisation. Before this, the figures were a
9
- * table in the bundle; the ticket's own condition for taking that away is that the replacement is
10
- * not "one fetch every time somebody opens the select". Prices move a few times a year, so an hour
11
- * is generous in the direction that matters.
12
- *
13
- * ⚠️ `retry: false`, like the tools screen's grouping query (#212). A React Query retry is PAUSED
14
- * while the tab is unfocused, so a select that waited on a retried query could sit unusable for as
15
- * long as the reader is looking elsewhere. This query only ENRICHES the select — the model can be
16
- * chosen without it — so it fails fast and the row simply shows no figures.
17
- */
18
- export function useModelCatalog(): UseQueryResult<ModelCatalog> {
19
- const { data } = useIntelRouterContext();
20
- return useQuery({
21
- queryKey: ["model-catalog"],
22
- queryFn: () => data.listModels(),
23
- staleTime: 60 * 60 * 1_000,
24
- retry: false,
25
- });
26
- }
@@ -1,149 +0,0 @@
1
- import { Check, ChevronsUpDown } from "lucide-react";
2
- import { useMemo, useState } from "react";
3
- import {
4
- Command,
5
- CommandEmpty,
6
- CommandGroup,
7
- CommandInput,
8
- CommandItem,
9
- CommandList,
10
- } from "@/components/ui/command";
11
- import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
12
- import { useI18n } from "@/i18n/i18n-context.tsx";
13
- import { cn } from "@/lib/utils";
14
- import {
15
- browserTimezone,
16
- type TimezoneOption,
17
- timezoneOption,
18
- timezoneOptions,
19
- } from "../timezone.ts";
20
-
21
- /**
22
- * Picking one of 418 timezones.
23
- *
24
- * ⚠️ A `Select` was the wrong shape and this replaces it (#243). Four hundred entries cannot be
25
- * scrolled to, and a bare zone name does not say whether you got the right one — `Europe/Berlin`
26
- * and `Europe/Madrid` are the same clock, `America/New_York` is not. So: type to search, and the
27
- * current offset stands in front of every name.
28
- *
29
- * ⚠️ The label is computed here, at render time, and never stored. A zone's offset AND its
30
- * abbreviation move with daylight saving, so a written-down label is wrong for half of every year.
31
- */
32
- interface TimezoneComboboxProps {
33
- id: string;
34
- value: string;
35
- onChange(zone: string): void;
36
- /** Handed in rather than read from the clock, so a test does not have to travel in time. */
37
- now?: Date;
38
- describedBy?: string;
39
- }
40
-
41
- function OptionLabel({ option }: { option: TimezoneOption }) {
42
- return (
43
- <>
44
- {/* Fixed width, so the zone names line up into a column instead of stepping left and right
45
- with every offset. Tabular figures keep "GMT+05:30" and "GMT-04:00" the same width, and
46
- ⚠️ `whitespace-nowrap` is not decoration: the longest label ("GMT-09:00 · HADT") wrapped
47
- onto a second line and pushed its own row out of the column. */}
48
- <span className="w-36 shrink-0 whitespace-nowrap text-xs tabular-nums text-muted-foreground">
49
- {option.offsetLabel}
50
- {option.abbreviation ? ` · ${option.abbreviation}` : ""}
51
- </span>
52
- <span className="truncate">{option.zone.replace(/_/g, " ")}</span>
53
- </>
54
- );
55
- }
56
-
57
- export function TimezoneCombobox({ id, value, onChange, now, describedBy }: TimezoneComboboxProps) {
58
- const i18n = useI18n();
59
- const [open, setOpen] = useState(false);
60
- const at = now ?? new Date();
61
- // The clock is a dependency on purpose: without it in the key, a session that crossed a DST
62
- // boundary would keep showing yesterday's offsets.
63
- const at_ = at.getTime();
64
-
65
- const options = useMemo(
66
- () => timezoneOptions(value, new Date(at_), i18n.locale),
67
- [value, at_, i18n.locale],
68
- );
69
- const selected = useMemo(
70
- () => timezoneOption(value, new Date(at_), i18n.locale),
71
- [value, at_, i18n.locale],
72
- );
73
- // The reader's own zone, pinned above the list: it is the answer in the overwhelming majority of
74
- // cases, and finding it in a sorted list of 418 is the work this saves.
75
- const own = useMemo(
76
- () => timezoneOption(browserTimezone(), new Date(at_), i18n.locale),
77
- [at_, i18n.locale],
78
- );
79
-
80
- const choose = (zone: string) => {
81
- onChange(zone);
82
- setOpen(false);
83
- };
84
-
85
- return (
86
- <Popover open={open} onOpenChange={setOpen}>
87
- <PopoverTrigger asChild>
88
- <button
89
- id={id}
90
- type="button"
91
- role="combobox"
92
- aria-expanded={open}
93
- aria-describedby={describedBy}
94
- className="flex w-full items-center gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
95
- >
96
- <OptionLabel option={selected} />
97
- <ChevronsUpDown aria-hidden="true" className="ml-auto size-4 shrink-0 opacity-50" />
98
- </button>
99
- </PopoverTrigger>
100
- <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
101
- {/* ⚠️ A substring filter, not cmdk's default fuzzy one. Fuzzy matches a scattered
102
- subsequence, so over 418 zones "berlin" also surfaced Brisbane, Dublin and Lisbon —
103
- technically a match, useless as an answer. Prefix beats contains, so typing a city puts
104
- that city first. An empty search returns 1 for everything, which leaves the list in DOM
105
- order: sorted west to east. */}
106
- <Command
107
- filter={(value, search, keywords) => {
108
- const needle = search.trim().toLowerCase();
109
- if (!needle) return 1;
110
- const haystack = [value.toLowerCase(), ...(keywords ?? [])];
111
- if (haystack.some((word) => word.startsWith(needle))) return 2;
112
- return haystack.some((word) => word.includes(needle)) ? 1 : 0;
113
- }}
114
- >
115
- <CommandInput placeholder={i18n.t("settings.timezoneSearch")} />
116
- <CommandList>
117
- <CommandEmpty>{i18n.t("settings.timezoneNoMatch")}</CommandEmpty>
118
- {own.zone !== value ? (
119
- <CommandGroup heading={i18n.t("settings.timezoneOwn")}>
120
- <CommandItem value={own.zone} keywords={own.keywords} onSelect={choose}>
121
- <OptionLabel option={own} />
122
- </CommandItem>
123
- </CommandGroup>
124
- ) : null}
125
- <CommandGroup heading={i18n.t("settings.timezoneAll")}>
126
- {options.map((option) => (
127
- <CommandItem
128
- key={option.zone}
129
- value={option.zone}
130
- keywords={option.keywords}
131
- onSelect={choose}
132
- >
133
- <OptionLabel option={option} />
134
- <Check
135
- aria-hidden="true"
136
- className={cn(
137
- "ml-auto size-4 shrink-0",
138
- option.zone === value ? "opacity-100" : "opacity-0",
139
- )}
140
- />
141
- </CommandItem>
142
- ))}
143
- </CommandGroup>
144
- </CommandList>
145
- </Command>
146
- </PopoverContent>
147
- </Popover>
148
- );
149
- }
@@ -1,65 +0,0 @@
1
- import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
2
- import { browserTimezone, readChosenTimezone, rememberTimezone } from "./timezone.ts";
3
-
4
- // The React seam of the timezone preference, the same shape as i18n-context and theme-context.
5
-
6
- export interface TimezoneSelection {
7
- /** The zone to read times in: the choice, or the browser's own where there is none. */
8
- current: string;
9
- /**
10
- * ⚠️ The zone somebody actually CHOSE — `null` while they never did (#256). `current` cannot
11
- * answer this: it falls back to the browser, and a screen that treated that as a preference would
12
- * annotate every schedule with a guess nobody made.
13
- */
14
- chosen: string | null;
15
- select(zone: string): void;
16
- }
17
-
18
- const TimezoneContext = createContext<TimezoneSelection | null>(null);
19
-
20
- interface TimezoneProviderProps {
21
- store?: Pick<Storage, "getItem" | "setItem">;
22
- children: ReactNode;
23
- }
24
-
25
- export function TimezoneProvider({ store, children }: TimezoneProviderProps) {
26
- const [chosen, setChosen] = useState(() => readChosenTimezone(store));
27
-
28
- const selection = useMemo<TimezoneSelection>(
29
- () => ({
30
- current: chosen ?? browserTimezone(),
31
- chosen,
32
- select: (zone) => {
33
- rememberTimezone(zone, store);
34
- setChosen(zone);
35
- },
36
- }),
37
- [chosen, store],
38
- );
39
-
40
- return <TimezoneContext value={selection}>{children}</TimezoneContext>;
41
- }
42
-
43
- // `null` without a provider — the settings dialog asks whether there is anything to choose.
44
- export function useTimezoneSelection(): TimezoneSelection | null {
45
- return useContext(TimezoneContext);
46
- }
47
-
48
- // The zone to read times in. Never null: a component without a provider still has to format a date,
49
- // and the browser's own zone is the honest answer there.
50
- export function useTimezone(): string {
51
- const selection = useContext(TimezoneContext);
52
- return selection?.current ?? browserTimezone();
53
- }
54
-
55
- /**
56
- * The zone the reader CHOSE, or `null` (#256).
57
- *
58
- * ⚠️ Not `useTimezone`. This is for screens that add the reader's own reading BESIDE a time that is
59
- * already correct — the agent's calendar. Nothing may be added for somebody who never set a zone:
60
- * the browser's guess presented as "your time" is a setting the reader did not make, and it would
61
- * put a second time on every row of a screen that was fine as it was.
62
- */
63
- export function useChosenTimezone(): string | null {
64
- return useContext(TimezoneContext)?.chosen ?? null;
65
- }