@nastechai/agent 0.16.0 → 0.17.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 (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
@@ -0,0 +1,394 @@
1
+ /**
2
+ * ChatSidebar — structured-events panel that sits next to the xterm.js
3
+ * terminal in the dashboard Chat tab.
4
+ *
5
+ * Two WebSockets, one per concern:
6
+ *
7
+ * 1. **JSON-RPC sidecar** (`GatewayClient` → /api/ws) — drives the
8
+ * sidebar's own slot of the dashboard's in-process gateway. Owns
9
+ * the model badge / picker / connection state / error banner.
10
+ * Independent of the PTY pane's session by design — those are the
11
+ * pieces the sidebar needs to be able to drive directly (model
12
+ * switch via slash.exec, etc.).
13
+ *
14
+ * 2. **Event subscriber** (/api/events?channel=…) — passive, receives
15
+ * every dispatcher emit from the PTY-side `tui_gateway.entry` that
16
+ * the dashboard fanned out. This is how `tool.start/progress/
17
+ * complete` from the agent loop reach the sidebar even though the
18
+ * PTY child runs three processes deep from us. The `channel` id
19
+ * ties this listener to the same chat tab's PTY child — see
20
+ * `ChatPage.tsx` for where the id is generated.
21
+ *
22
+ * Best-effort throughout: WS failures show in the badge / banner, the
23
+ * terminal pane keeps working unimpaired.
24
+ */
25
+
26
+ import { Button } from "@nastechai/ui/ui/components/button";
27
+ import { Badge } from "@nastechai/ui/ui/components/badge";
28
+ import { Card } from "@nastechai/ui/ui/components/card";
29
+
30
+ import { ModelPickerDialog } from "@/components/ModelPickerDialog";
31
+ import { ToolCall, type ToolEntry } from "@/components/ToolCall";
32
+ import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
33
+ import { NASTECH_BASE_PATH, buildWsAuthParam } from "@/lib/api";
34
+
35
+ import { cn } from "@/lib/utils";
36
+ import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
37
+ import { useCallback, useEffect, useMemo, useState } from "react";
38
+
39
+ interface SessionInfo {
40
+ cwd?: string;
41
+ model?: string;
42
+ provider?: string;
43
+ credential_warning?: string;
44
+ }
45
+
46
+ interface RpcEnvelope {
47
+ method?: string;
48
+ params?: { type?: string; payload?: unknown };
49
+ }
50
+
51
+ const TOOL_LIMIT = 20;
52
+
53
+ const STATE_LABEL: Record<ConnectionState, string> = {
54
+ idle: "idle",
55
+ connecting: "connecting",
56
+ open: "live",
57
+ closed: "closed",
58
+ error: "error",
59
+ };
60
+
61
+ const STATE_TONE: Record<
62
+ ConnectionState,
63
+ "secondary" | "warning" | "success" | "destructive"
64
+ > = {
65
+ idle: "secondary",
66
+ connecting: "warning",
67
+ open: "success",
68
+ closed: "secondary",
69
+ error: "destructive",
70
+ };
71
+
72
+ interface ChatSidebarProps {
73
+ channel: string;
74
+ className?: string;
75
+ }
76
+
77
+ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
78
+ // `version` bumps on reconnect; gw is derived so we never call setState
79
+ // for it inside an effect (React 19's set-state-in-effect rule). The
80
+ // counter is the dependency on purpose — it's not read in the memo body,
81
+ // it's the signal that says "rebuild the client".
82
+ const [version, setVersion] = useState(0);
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ const gw = useMemo(() => new GatewayClient(), [version]);
85
+
86
+ const [state, setState] = useState<ConnectionState>("idle");
87
+ const [sessionId, setSessionId] = useState<string | null>(null);
88
+ const [info, setInfo] = useState<SessionInfo>({});
89
+ const [tools, setTools] = useState<ToolEntry[]>([]);
90
+ const [modelOpen, setModelOpen] = useState(false);
91
+ const [error, setError] = useState<string | null>(null);
92
+
93
+ useEffect(() => {
94
+ let cancelled = false;
95
+ const offState = gw.onState(setState);
96
+
97
+ const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => {
98
+ if (ev.session_id) {
99
+ setSessionId(ev.session_id);
100
+ }
101
+
102
+ if (ev.payload) {
103
+ setInfo((prev) => ({ ...prev, ...ev.payload }));
104
+ }
105
+ });
106
+
107
+ const offError = gw.on<{ message?: string }>("error", (ev) => {
108
+ const message = ev.payload?.message;
109
+
110
+ if (message) {
111
+ setError(message);
112
+ }
113
+ });
114
+
115
+ // Adopt whichever session the gateway hands us. session.create on the
116
+ // sidecar is independent of the PTY pane's session by design — we
117
+ // only need a sid to drive the model picker's slash.exec calls.
118
+ gw.connect()
119
+ .then(() => {
120
+ if (cancelled) {
121
+ return;
122
+ }
123
+ return gw.request<{ session_id: string }>("session.create", {});
124
+ })
125
+ .then((created) => {
126
+ if (cancelled || !created?.session_id) {
127
+ return;
128
+ }
129
+ setSessionId(created.session_id);
130
+ })
131
+ .catch((e: Error) => {
132
+ if (!cancelled) {
133
+ setError(e.message);
134
+ }
135
+ });
136
+
137
+ return () => {
138
+ cancelled = true;
139
+ offState();
140
+ offSessionInfo();
141
+ offError();
142
+ gw.close();
143
+ };
144
+ }, [gw]);
145
+
146
+ // Event subscriber WebSocket — receives the rebroadcast of every
147
+ // dispatcher emit from the PTY child's gateway. See /api/pub +
148
+ // /api/events in nastech_cli/web_server.py for the broadcast hop.
149
+ //
150
+ // Failures (auth/loopback rejection, server too old to expose the
151
+ // endpoint, transient drops) surface in the same banner as the
152
+ // JSON-RPC sidecar so the sidebar matches its documented best-effort
153
+ // UX and the user always has a reconnect affordance.
154
+ useEffect(() => {
155
+ if (!channel) {
156
+ return;
157
+ }
158
+ // In loopback mode the legacy ?token=<session> path is fine; in gated
159
+ // mode we have to mint a single-use ticket from the cookie. The IIFE
160
+ // keeps the outer effect synchronous so its ``return cleanup`` stays
161
+ // at the top level; the local ``ws`` is hoisted to a closed-over
162
+ // binding the cleanup reads via ``wsRef``.
163
+ let unmounting = false;
164
+ let ws: WebSocket | null = null;
165
+ void (async () => {
166
+ const [authName, authValue] = await buildWsAuthParam();
167
+ if (!authValue || unmounting) {
168
+ return;
169
+ }
170
+ const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
171
+ const qs = new URLSearchParams({ [authName]: authValue, channel });
172
+ ws = new WebSocket(
173
+ `${proto}//${window.location.host}${NASTECH_BASE_PATH}/api/events?${qs.toString()}`,
174
+ );
175
+
176
+ // `unmounting` suppresses the banner during cleanup — `ws.close()`
177
+ // from the effect's return fires a close event with code 1005 that
178
+ // would otherwise look like an unexpected drop.
179
+ const DISCONNECTED = "events feed disconnected — tool calls may not appear";
180
+ const surface = (msg: string) => !unmounting && setError(msg);
181
+
182
+ ws.addEventListener("error", () => surface(DISCONNECTED));
183
+
184
+ ws.addEventListener("close", (ev) => {
185
+ if (ev.code === 4401 || ev.code === 4403) {
186
+ surface(`events feed rejected (${ev.code}) — reload the page`);
187
+ } else if (ev.code !== 1000) {
188
+ surface(DISCONNECTED);
189
+ }
190
+ });
191
+
192
+ ws.addEventListener("message", (ev) => {
193
+ let frame: RpcEnvelope;
194
+
195
+ try {
196
+ frame = JSON.parse(ev.data);
197
+ } catch {
198
+ return;
199
+ }
200
+
201
+ if (frame.method !== "event" || !frame.params) {
202
+ return;
203
+ }
204
+
205
+ const { type, payload } = frame.params;
206
+
207
+ if (type === "tool.start") {
208
+ const p = payload as
209
+ | { tool_id?: string; name?: string; context?: string }
210
+ | undefined;
211
+ const toolId = p?.tool_id;
212
+
213
+ if (!toolId) {
214
+ return;
215
+ }
216
+
217
+ setTools((prev) =>
218
+ [
219
+ ...prev,
220
+ {
221
+ kind: "tool" as const,
222
+ id: `tool-${toolId}-${prev.length}`,
223
+ tool_id: toolId,
224
+ name: p?.name ?? "tool",
225
+ context: p?.context,
226
+ status: "running" as const,
227
+ startedAt: Date.now(),
228
+ },
229
+ ].slice(-TOOL_LIMIT),
230
+ );
231
+ } else if (type === "tool.progress") {
232
+ const p = payload as
233
+ | { name?: string; preview?: string }
234
+ | undefined;
235
+
236
+ if (!p?.name || !p.preview) {
237
+ return;
238
+ }
239
+
240
+ setTools((prev) =>
241
+ prev.map((t) =>
242
+ t.status === "running" && t.name === p.name
243
+ ? { ...t, preview: p.preview }
244
+ : t,
245
+ ),
246
+ );
247
+ } else if (type === "tool.complete") {
248
+ const p = payload as
249
+ | {
250
+ tool_id?: string;
251
+ summary?: string;
252
+ error?: string;
253
+ inline_diff?: string;
254
+ }
255
+ | undefined;
256
+
257
+ if (!p?.tool_id) {
258
+ return;
259
+ }
260
+
261
+ setTools((prev) =>
262
+ prev.map((t) =>
263
+ t.tool_id === p.tool_id
264
+ ? {
265
+ ...t,
266
+ status: p.error ? "error" : "done",
267
+ summary: p.summary,
268
+ error: p.error,
269
+ inline_diff: p.inline_diff,
270
+ completedAt: Date.now(),
271
+ }
272
+ : t,
273
+ ),
274
+ );
275
+ }
276
+ });
277
+ })();
278
+
279
+ return () => {
280
+ unmounting = true;
281
+ ws?.close();
282
+ };
283
+ }, [channel, version]);
284
+
285
+ const reconnect = useCallback(() => {
286
+ setError(null);
287
+ setTools([]);
288
+ setVersion((v) => v + 1);
289
+ }, []);
290
+
291
+ // Picker hands us a fully-formed slash command (e.g. "/model anthropic/...").
292
+ // Fire-and-forget through `slash.exec`; the TUI pane will render the result
293
+ // via PTY, so the sidebar doesn't need to surface output of its own.
294
+ const onModelSubmit = useCallback(
295
+ (slashCommand: string) => {
296
+ if (!sessionId) {
297
+ return;
298
+ }
299
+
300
+ void gw.request("slash.exec", {
301
+ session_id: sessionId,
302
+ command: slashCommand,
303
+ });
304
+ setModelOpen(false);
305
+ },
306
+ [gw, sessionId],
307
+ );
308
+
309
+ const canPickModel = state === "open" && !!sessionId;
310
+ const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—";
311
+ const banner = error ?? info.credential_warning ?? null;
312
+
313
+ return (
314
+ <aside
315
+ className={cn(
316
+ "flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 lg:w-80",
317
+ className,
318
+ )}
319
+ >
320
+ <Card className="flex items-center justify-between gap-2 px-3 py-2">
321
+ <div className="min-w-0">
322
+ <div className="text-display text-xs tracking-wider text-text-tertiary">
323
+ model
324
+ </div>
325
+
326
+ <Button
327
+ ghost
328
+ size="sm"
329
+ disabled={!canPickModel}
330
+ onClick={() => setModelOpen(true)}
331
+ suffix={
332
+ canPickModel ? (
333
+ <ChevronDown className="text-text-secondary" />
334
+ ) : undefined
335
+ }
336
+ className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline"
337
+ title={info.model ?? "switch model"}
338
+ >
339
+ <span className="truncate">{modelLabel}</span>
340
+ </Button>
341
+ </div>
342
+
343
+ <Badge tone={STATE_TONE[state]}>{STATE_LABEL[state]}</Badge>
344
+ </Card>
345
+
346
+ {banner && (
347
+ <Card className="flex items-start gap-2 border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
348
+ <AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
349
+
350
+ <div className="min-w-0 flex-1">
351
+ <div className="wrap-break-word text-destructive">{banner}</div>
352
+
353
+ {error && (
354
+ <Button
355
+ size="sm"
356
+ outlined
357
+ className="mt-1"
358
+ onClick={reconnect}
359
+ prefix={<RefreshCw />}
360
+ >
361
+ reconnect
362
+ </Button>
363
+ )}
364
+ </div>
365
+ </Card>
366
+ )}
367
+
368
+ <Card className="flex min-h-0 flex-none flex-col px-2 py-2">
369
+ <div className="text-display px-1 pb-2 text-xs tracking-wider text-text-tertiary">
370
+ tools
371
+ </div>
372
+
373
+ <div className="flex min-h-0 flex-col gap-1.5">
374
+ {tools.length === 0 ? (
375
+ <div className="px-2 py-4 text-center text-xs text-text-secondary">
376
+ no tool calls yet
377
+ </div>
378
+ ) : (
379
+ tools.map((t) => <ToolCall key={t.id} tool={t} />)
380
+ )}
381
+ </div>
382
+ </Card>
383
+
384
+ {modelOpen && canPickModel && sessionId && (
385
+ <ModelPickerDialog
386
+ gw={gw}
387
+ sessionId={sessionId}
388
+ onClose={() => setModelOpen(false)}
389
+ onSubmit={onModelSubmit}
390
+ />
391
+ )}
392
+ </aside>
393
+ );
394
+ }
@@ -0,0 +1,40 @@
1
+ import { ConfirmDialog } from "@nastechai/ui/ui/components/confirm-dialog";
2
+ import { useI18n } from "@/i18n";
3
+
4
+ export function DeleteConfirmDialog({
5
+ cancelLabel,
6
+ confirmLabel,
7
+ description,
8
+ loading,
9
+ onCancel,
10
+ onConfirm,
11
+ open,
12
+ title,
13
+ }: DeleteConfirmDialogProps) {
14
+ const { t } = useI18n();
15
+
16
+ return (
17
+ <ConfirmDialog
18
+ open={open}
19
+ onCancel={onCancel}
20
+ onConfirm={onConfirm}
21
+ title={title}
22
+ description={description}
23
+ loading={loading}
24
+ destructive
25
+ confirmLabel={confirmLabel ?? t.common.delete}
26
+ cancelLabel={cancelLabel ?? t.common.cancel}
27
+ />
28
+ );
29
+ }
30
+
31
+ interface DeleteConfirmDialogProps {
32
+ cancelLabel?: string;
33
+ confirmLabel?: string;
34
+ description?: string;
35
+ loading: boolean;
36
+ onCancel: () => void;
37
+ onConfirm: () => void;
38
+ open: boolean;
39
+ title: string;
40
+ }
@@ -0,0 +1,186 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Check } from "lucide-react";
4
+ import { Button } from "@nastechai/ui/ui/components/button";
5
+ import { BottomSheet } from "@nastechai/ui/ui/components/bottom-sheet";
6
+ import { Typography } from "@nastechai/ui/ui/components/typography/index";
7
+ import { useBelowBreakpoint } from "@nastechai/ui/hooks/use-below-breakpoint";
8
+ import { useI18n } from "@/i18n/context";
9
+ import { LOCALE_META } from "@/i18n";
10
+ import type { Locale } from "@/i18n";
11
+ import { cn } from "@/lib/utils";
12
+
13
+ /**
14
+ * Language picker — shows the current language's endonym, opens a dropdown
15
+ * of all supported locales when clicked. Persists choice to localStorage via
16
+ * the I18n context.
17
+ *
18
+ * Replaces the older two-state EN↔ZH toggle now that we ship 16 locales
19
+ * (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu).
20
+ *
21
+ * No country flags by design — languages aren't countries, and flag pairings
22
+ * inevitably create political mismappings (e.g. Mandarin variants ≠ any single
23
+ * jurisdiction, English ≠ GB, Portuguese ≠ PT). Endonyms are unambiguous.
24
+ *
25
+ * When placed at the bottom of the sidebar (next to ThemeSwitcher), pass
26
+ * `dropUp` so the list opens above the trigger and avoids clipping below the
27
+ * viewport / overflow ancestors. Below the `sm` breakpoint, `dropUp` uses a
28
+ * bottom sheet portaled to `document.body` instead of an anchored dropdown.
29
+ */
30
+ export function LanguageSwitcher({ collapsed = false, dropUp = false }: LanguageSwitcherProps) {
31
+ const { locale, setLocale, t } = useI18n();
32
+ const [open, setOpen] = useState(false);
33
+ const containerRef = useRef<HTMLDivElement>(null);
34
+ const dropdownRef = useRef<HTMLDivElement>(null);
35
+ const narrowViewport = useBelowBreakpoint(640);
36
+ const useMobileSheet = Boolean(dropUp && narrowViewport);
37
+
38
+ useEffect(() => {
39
+ if (!open) return;
40
+ function onKey(e: KeyboardEvent) {
41
+ if (e.key === "Escape") setOpen(false);
42
+ }
43
+ document.addEventListener("keydown", onKey);
44
+ return () => document.removeEventListener("keydown", onKey);
45
+ }, [open]);
46
+
47
+ useEffect(() => {
48
+ if (!open || useMobileSheet) return;
49
+
50
+ function onPointerDown(e: PointerEvent) {
51
+ const target = e.target as Node;
52
+ if (containerRef.current?.contains(target)) return;
53
+ if (dropdownRef.current?.contains(target)) return;
54
+ setOpen(false);
55
+ }
56
+
57
+ document.addEventListener("pointerdown", onPointerDown);
58
+ return () => document.removeEventListener("pointerdown", onPointerDown);
59
+ }, [open, useMobileSheet]);
60
+
61
+ const current = LOCALE_META[locale];
62
+ const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>;
63
+ const sheetTitle = t.language.switchTo;
64
+
65
+ return (
66
+ <div ref={containerRef} className="relative inline-flex">
67
+ <Button
68
+ ghost
69
+ onClick={() => setOpen((v) => !v)}
70
+ title={t.language.switchTo}
71
+ aria-label={t.language.switchTo}
72
+ aria-haspopup="listbox"
73
+ aria-expanded={open}
74
+ className={cn(
75
+ "px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
76
+ collapsed && "hover:bg-transparent",
77
+ )}
78
+ >
79
+ <span className="inline-flex items-center gap-1.5">
80
+ <Typography
81
+ mondwest
82
+ className="hidden sm:inline text-display tracking-wide text-xs"
83
+ >
84
+ {locale === "en" ? "EN" : current.name}
85
+ </Typography>
86
+ </span>
87
+ </Button>
88
+
89
+ {useMobileSheet && (
90
+ <BottomSheet
91
+ backdropDismissLabel={t.common.close}
92
+ onClose={() => setOpen(false)}
93
+ open={open}
94
+ title={sheetTitle}
95
+ >
96
+ <div aria-label={sheetTitle} role="listbox">
97
+ <LanguageSwitcherOptions
98
+ allLocales={allLocales}
99
+ locale={locale}
100
+ setLocale={setLocale}
101
+ setOpen={setOpen}
102
+ />
103
+ </div>
104
+ </BottomSheet>
105
+ )}
106
+
107
+ {open && !useMobileSheet && (() => {
108
+ const rect = containerRef.current?.getBoundingClientRect();
109
+ const dropdown = (
110
+ <div
111
+ ref={dropdownRef}
112
+ aria-label={sheetTitle}
113
+ className={cn(
114
+ "min-w-[10rem] border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
115
+ dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
116
+ )}
117
+ role="listbox"
118
+ style={
119
+ dropUp && rect
120
+ ? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
121
+ : undefined
122
+ }
123
+ >
124
+ <LanguageSwitcherOptions
125
+ allLocales={allLocales}
126
+ locale={locale}
127
+ setLocale={setLocale}
128
+ setOpen={setOpen}
129
+ />
130
+ </div>
131
+ );
132
+ return dropUp ? createPortal(dropdown, document.body) : dropdown;
133
+ })()}
134
+ </div>
135
+ );
136
+ }
137
+
138
+ function LanguageSwitcherOptions({
139
+ allLocales,
140
+ locale,
141
+ setLocale,
142
+ setOpen,
143
+ }: LanguageSwitcherOptionsProps) {
144
+ return (
145
+ <>
146
+ {allLocales.map(([code, meta]) => {
147
+ const selected = code === locale;
148
+
149
+ return (
150
+ <button
151
+ aria-selected={selected}
152
+ className={cn(
153
+ "w-full text-left px-3 py-1.5 flex items-center gap-2 cursor-pointer",
154
+ "font-mondwest text-display text-xs tracking-[0.08em]",
155
+ "hover:bg-accent hover:text-accent-foreground transition-colors",
156
+ selected ? "font-semibold text-foreground" : "text-muted-foreground",
157
+ )}
158
+ key={code}
159
+ onClick={() => {
160
+ setLocale(code);
161
+ setOpen(false);
162
+ }}
163
+ role="option"
164
+ type="button"
165
+ >
166
+ <span className="truncate">{meta.name}</span>
167
+
168
+ {selected && <Check className="ml-auto h-3 w-3 shrink-0 text-midground" />}
169
+ </button>
170
+ );
171
+ })}
172
+ </>
173
+ );
174
+ }
175
+
176
+ interface LanguageSwitcherOptionsProps {
177
+ allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>;
178
+ locale: Locale;
179
+ setLocale: (code: Locale) => void;
180
+ setOpen: (open: boolean) => void;
181
+ }
182
+
183
+ interface LanguageSwitcherProps {
184
+ collapsed?: boolean;
185
+ dropUp?: boolean;
186
+ }