@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.
- package/eslint.config.js +23 -0
- package/index.html +24 -0
- package/package.json +54 -26
- package/package.json.bak +89 -0
- package/package.json.pub +88 -0
- package/src/App.tsx +1173 -0
- package/src/components/AuthWidget.tsx +150 -0
- package/src/components/AutoField.tsx +206 -0
- package/src/components/Backdrop.tsx +93 -0
- package/src/components/ChatSidebar.tsx +394 -0
- package/src/components/DeleteConfirmDialog.tsx +40 -0
- package/src/components/LanguageSwitcher.tsx +186 -0
- package/src/components/Markdown.tsx +383 -0
- package/src/components/ModelInfoCard.tsx +112 -0
- package/src/components/ModelPickerDialog.tsx +470 -0
- package/src/components/OAuthLoginModal.tsx +374 -0
- package/src/components/OAuthProvidersCard.tsx +287 -0
- package/src/components/PlatformsCard.tsx +97 -0
- package/src/components/ScheduleBuilder.tsx +273 -0
- package/src/components/SidebarFooter.tsx +42 -0
- package/src/components/SidebarStatusStrip.tsx +72 -0
- package/src/components/SlashPopover.tsx +171 -0
- package/src/components/ThemeSwitcher.tsx +243 -0
- package/src/components/ToolCall.tsx +228 -0
- package/src/components/ToolsetConfigDrawer.tsx +448 -0
- package/src/contexts/PageHeaderProvider.tsx +139 -0
- package/src/contexts/SystemActions.tsx +120 -0
- package/src/contexts/page-header-context.ts +12 -0
- package/src/contexts/system-actions-context.ts +18 -0
- package/src/contexts/usePageHeader.ts +10 -0
- package/src/contexts/useSystemActions.ts +15 -0
- package/src/hooks/useModalBehavior.ts +44 -0
- package/src/hooks/useSidebarStatus.ts +27 -0
- package/src/i18n/af.ts +702 -0
- package/src/i18n/context.tsx +123 -0
- package/src/i18n/de.ts +701 -0
- package/src/i18n/en.ts +708 -0
- package/src/i18n/es.ts +701 -0
- package/src/i18n/fr.ts +701 -0
- package/src/i18n/ga.ts +702 -0
- package/src/i18n/hu.ts +702 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/it.ts +701 -0
- package/src/i18n/ja.ts +702 -0
- package/src/i18n/ko.ts +702 -0
- package/src/i18n/pt.ts +702 -0
- package/src/i18n/ru.ts +702 -0
- package/src/i18n/tr.ts +702 -0
- package/src/i18n/types.ts +710 -0
- package/src/i18n/uk.ts +702 -0
- package/src/i18n/zh-hant.ts +702 -0
- package/src/i18n/zh.ts +698 -0
- package/src/index.css +274 -0
- package/src/lib/api.ts +1585 -0
- package/src/lib/dashboard-flags.ts +15 -0
- package/src/lib/format.ts +9 -0
- package/src/lib/fuzzy.ts +192 -0
- package/src/lib/gatewayClient.ts +253 -0
- package/src/lib/nested.ts +23 -0
- package/src/lib/resolve-page-title.ts +41 -0
- package/src/lib/schedule.ts +382 -0
- package/src/lib/slashExec.ts +163 -0
- package/src/lib/utils.ts +35 -0
- package/src/main.tsx +25 -0
- package/src/pages/AnalyticsPage.tsx +601 -0
- package/src/pages/ChannelsPage.tsx +772 -0
- package/src/pages/ChatPage.tsx +889 -0
- package/src/pages/ConfigPage.tsx +660 -0
- package/src/pages/CronPage.tsx +524 -0
- package/src/pages/DocsPage.tsx +69 -0
- package/src/pages/EnvPage.tsx +918 -0
- package/src/pages/LogsPage.tsx +246 -0
- package/src/pages/McpPage.tsx +757 -0
- package/src/pages/ModelsPage.tsx +994 -0
- package/src/pages/PairingPage.tsx +276 -0
- package/src/pages/PluginsPage.tsx +580 -0
- package/src/pages/ProfilesPage.tsx +559 -0
- package/src/pages/SessionsPage.tsx +936 -0
- package/src/pages/SkillsPage.tsx +557 -0
- package/src/pages/SystemPage.tsx +1259 -0
- package/src/pages/WebhooksPage.tsx +483 -0
- package/src/plugins/PluginPage.tsx +64 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/registry.ts +151 -0
- package/src/plugins/sdk.d.ts +160 -0
- package/src/plugins/slots.ts +199 -0
- package/src/plugins/types.ts +37 -0
- package/src/plugins/usePlugins.ts +133 -0
- package/src/themes/context.tsx +443 -0
- package/src/themes/fonts.ts +160 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/presets.ts +477 -0
- package/src/themes/types.ts +187 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +124 -0
- 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
|
+
}
|