@anchrd/intel-ui 0.17.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 -213
  8. package/src/i18n/en.json +6 -213
  9. package/src/i18n/es.json +6 -213
  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 +5 -4
  14. package/src/nodes/nodes.tsx +0 -34
  15. package/src/resource-menu/resource-menu.tsx +68 -20
  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 -135
  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 -988
  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 -482
  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 -97
  46. package/src/board/board-kanban/board-kanban.tsx +0 -211
  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 -245
  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,245 +0,0 @@
1
- import {
2
- type Announcements,
3
- closestCorners,
4
- DndContext,
5
- DragOverlay,
6
- type DragStartEvent,
7
- KeyboardSensor,
8
- PointerSensor,
9
- useDroppable,
10
- useSensor,
11
- useSensors,
12
- } from "@dnd-kit/core";
13
- import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
14
- import { CSS } from "@dnd-kit/utilities";
15
- import { GripVertical } from "lucide-react";
16
- import { type ReactNode, useState } from "react";
17
- import { createPortal } from "react-dom";
18
- import { cn } from "@/lib/utils";
19
-
20
- /**
21
- * A kanban board on dnd-kit, from the Kibo UI registry (MIT) and changed where it had to be.
22
- *
23
- * Registry code is our code, and four things about the original do not survive contact with a board
24
- * whose truth is on a server:
25
- *
26
- * ⚠️ **It reported a drop as a new ARRAY, and it reported one twice.** The original called
27
- * `onDataChange` in `onDragOver` for a cross-column move and again in `onDragEnd`, and both times
28
- * with a reordered copy of the whole list. Against a server that is two writes for one drag — and
29
- * the second one describes a state the first already changed. This version says nothing while the
30
- * card is in the air and emits ONE drop at the end, naming what was dropped and what it was dropped
31
- * on. What that means is the caller's to decide (`board-kanban.ts`), which is also where the single
32
- * `board_task_move` is sent from.
33
- *
34
- * ⚠️ **It mutated its own props** — `newData[activeIndex].column = overColumn` writes into the
35
- * object the caller passed in. With TanStack Query holding that object, a drag edited the cache in
36
- * place and no re-render followed.
37
- *
38
- * ⚠️ **`tunnel-rat` is gone.** It existed to render the dragged card into the overlay from inside
39
- * the card component; `DragOverlay` already does that, and the caller draws the overlay itself.
40
- * One dependency fewer for something React has a portal for.
41
- *
42
- * ⚠️ **The screen reader announcements are handed in.** The originals are English string literals
43
- * in the component; Intel speaks three languages and its text comes from `useI18n()`.
44
- *
45
- * ⚠️ **The card is not itself a `role="button"`.** The original spread `useSortable().attributes`
46
- * onto the card, which contributes `role="button"` and `tabIndex={0}` — and a card that also has to
47
- * be OPENED then holds a real button inside that one. Two tab stops computing the same accessible
48
- * name from the same contents, and an Enter that means "start dragging" on the outside and "open
49
- * this" on the inside. Split instead: the card keeps `listeners`, so a pointer can still drag it
50
- * from anywhere, and `attributes` moves to a handle that is the one tab stop for dragging and says
51
- * so.
52
- */
53
-
54
- export interface KanbanColumn {
55
- id: string;
56
- name: string;
57
- }
58
-
59
- export interface KanbanDrop {
60
- activeId: string;
61
- // The column or the card it was let go over. `null` means it was let go over nothing — a drag
62
- // that ended outside the board, which is a cancel and not a move.
63
- overId: string | null;
64
- }
65
-
66
- export function KanbanBoard({
67
- id,
68
- droppable = true,
69
- children,
70
- className,
71
- }: {
72
- id: string;
73
- // A column that exists to be READ but must not be written into. It still draws and still holds
74
- // cards; it simply never becomes a drop target, so nothing can be dropped where the answer would
75
- // be a refusal (`board-kanban.tsx`).
76
- droppable?: boolean;
77
- children: ReactNode;
78
- className?: string;
79
- }) {
80
- const { isOver, setNodeRef } = useDroppable({ id, disabled: !droppable });
81
- return (
82
- <div
83
- ref={setNodeRef}
84
- data-slot="kanban-column"
85
- data-droppable={droppable}
86
- className={cn(
87
- "flex size-full min-h-40 flex-col divide-y overflow-hidden rounded-lg border bg-muted/40 text-xs ring-2 transition-colors",
88
- isOver ? "ring-ring" : "ring-transparent",
89
- className,
90
- )}
91
- >
92
- {children}
93
- </div>
94
- );
95
- }
96
-
97
- export function KanbanHeader({ children }: { children: ReactNode }) {
98
- return <div className="flex items-center gap-2 p-2 text-sm font-semibold">{children}</div>;
99
- }
100
-
101
- export function KanbanCards({ id, children }: { id: string; children: ReactNode }) {
102
- return (
103
- <div
104
- // The column scrolls, not the page: four lanes of different lengths must not make the board
105
- // as tall as its longest one.
106
- className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2"
107
- data-slot="kanban-cards"
108
- data-column={id}
109
- >
110
- {children}
111
- </div>
112
- );
113
- }
114
-
115
- export function KanbanCard({
116
- id,
117
- dragLabel,
118
- children,
119
- className,
120
- }: {
121
- id: string;
122
- // What the drag handle is called. Handed in because only the caller knows what the card is of.
123
- dragLabel: string;
124
- children: ReactNode;
125
- className?: string;
126
- }) {
127
- const {
128
- attributes,
129
- listeners,
130
- setNodeRef,
131
- setActivatorNodeRef,
132
- transition,
133
- transform,
134
- isDragging,
135
- } = useSortable({ id });
136
- return (
137
- <div
138
- ref={setNodeRef}
139
- style={{ transition, transform: CSS.Transform.toString(transform) }}
140
- // ⚠️ `listeners` here, `attributes` NOT. The pointer sensor has to see a press anywhere on the
141
- // card — a kanban whose cards can only be dragged by a grip is a kanban nobody drags — but
142
- // `attributes` is what carries `role="button"` and `tabIndex`, and those on the card would
143
- // wrap the control that OPENS it in a second button. Pointer drag from the whole card,
144
- // keyboard drag from the handle, one tab stop each.
145
- //
146
- // ⚠️ The split only holds because the handle registers itself as the ACTIVATOR below. Without
147
- // that, `listeners` on this element make every Enter and Space inside the card a drag: the
148
- // key handler bubbles up from whatever is focused, `KeyboardSensor` accepts it and calls
149
- // `preventDefault()`, and the button that opens the task never sees its own activation.
150
- {...listeners}
151
- className={cn(
152
- "flex cursor-grab items-start gap-1 rounded-lg border bg-card p-3 text-left shadow-sm",
153
- // Left where it was, faded, rather than removed: a lane that reflows while a card is over
154
- // it moves the drop target out from under the pointer.
155
- isDragging && "opacity-40",
156
- className,
157
- )}
158
- >
159
- {/* The keyboard half of the drag: `attributes` puts the card into the tab order exactly once,
160
- here, where it has a name of its own that says what it does.
161
-
162
- ⚠️ `setActivatorNodeRef` is what makes that true rather than merely intended. dnd-kit's
163
- `KeyboardSensor` opens with `if (activator && event.target !== activator) return false` —
164
- a guard that only exists once something has claimed to BE the activator. Unclaimed, the
165
- keyboard sensor accepts a key press from anywhere under the listeners, so Enter on the
166
- card's own "open" button started a drag and swallowed the click. The pointer drag is
167
- unaffected: `PointerSensor` never consults the activator node. */}
168
- <button
169
- type="button"
170
- ref={setActivatorNodeRef}
171
- aria-label={dragLabel}
172
- {...listeners}
173
- {...attributes}
174
- className="mt-0.5 shrink-0 cursor-grab rounded text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
175
- >
176
- <GripVertical aria-hidden="true" className="size-4" />
177
- </button>
178
- <div className="min-w-0 flex-1">{children}</div>
179
- </div>
180
- );
181
- }
182
-
183
- export function KanbanProvider({
184
- columns,
185
- itemsByColumn,
186
- children,
187
- announcements,
188
- overlay,
189
- onDrop,
190
- className,
191
- }: {
192
- columns: KanbanColumn[];
193
- // Which cards stand in which column, in the order they are drawn. The provider needs it for the
194
- // sortable contexts and asks for it rather than deriving it, so the caller stays the only place
195
- // that knows how a board is ordered.
196
- itemsByColumn: Map<string, string[]>;
197
- children: (column: KanbanColumn) => ReactNode;
198
- announcements: Announcements;
199
- overlay(activeId: string): ReactNode;
200
- onDrop(drop: KanbanDrop): void;
201
- className?: string;
202
- }) {
203
- const [activeId, setActiveId] = useState<string | null>(null);
204
- const sensors = useSensors(
205
- // ⚠️ A distance before a drag starts, and it is not decoration: without it every click on a
206
- // card is a drag of zero pixels, and the card can then no longer be opened by clicking it.
207
- useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
208
- useSensor(KeyboardSensor),
209
- );
210
-
211
- return (
212
- <DndContext
213
- accessibility={{ announcements }}
214
- // Corners rather than centres: a tall card over a short one wins on centre distance even
215
- // when the pointer is nowhere near it.
216
- collisionDetection={closestCorners}
217
- sensors={sensors}
218
- onDragStart={(event: DragStartEvent) => setActiveId(String(event.active.id))}
219
- onDragCancel={() => setActiveId(null)}
220
- onDragEnd={(event) => {
221
- setActiveId(null);
222
- const over = event.over === null ? null : String(event.over.id);
223
- onDrop({ activeId: String(event.active.id), overId: over });
224
- }}
225
- >
226
- <div className={cn("grid size-full auto-cols-fr grid-flow-col gap-3", className)}>
227
- {columns.map((column) => (
228
- <SortableContext
229
- key={column.id}
230
- items={itemsByColumn.get(column.id) ?? []}
231
- strategy={verticalListSortingStrategy}
232
- >
233
- {children(column)}
234
- </SortableContext>
235
- ))}
236
- </div>
237
- {typeof document === "undefined"
238
- ? null
239
- : createPortal(
240
- <DragOverlay>{activeId === null ? null : overlay(activeId)}</DragOverlay>,
241
- document.body,
242
- )}
243
- </DndContext>
244
- );
245
- }
@@ -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
- }