@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,559 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react";
|
|
8
|
+
import {
|
|
9
|
+
ChevronDown,
|
|
10
|
+
Pencil,
|
|
11
|
+
Terminal,
|
|
12
|
+
Trash2,
|
|
13
|
+
Users,
|
|
14
|
+
X,
|
|
15
|
+
} from "lucide-react";
|
|
16
|
+
import spinners from "unicode-animations";
|
|
17
|
+
import { H2 } from "@nastechai/ui/ui/components/typography/h2";
|
|
18
|
+
import { api } from "@/lib/api";
|
|
19
|
+
import type { ProfileInfo } from "@/lib/api";
|
|
20
|
+
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
|
21
|
+
import { useToast } from "@nastechai/ui/hooks/use-toast";
|
|
22
|
+
import { useConfirmDelete } from "@nastechai/ui/hooks/use-confirm-delete";
|
|
23
|
+
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
|
24
|
+
import { Toast } from "@nastechai/ui/ui/components/toast";
|
|
25
|
+
import { Card, CardContent } from "@nastechai/ui/ui/components/card";
|
|
26
|
+
import { Badge } from "@nastechai/ui/ui/components/badge";
|
|
27
|
+
import { Button } from "@nastechai/ui/ui/components/button";
|
|
28
|
+
import { Input } from "@nastechai/ui/ui/components/input";
|
|
29
|
+
import { Label } from "@nastechai/ui/ui/components/label";
|
|
30
|
+
import { Checkbox } from "@nastechai/ui/ui/components/checkbox";
|
|
31
|
+
import { useI18n } from "@/i18n";
|
|
32
|
+
import { usePageHeader } from "@/contexts/usePageHeader";
|
|
33
|
+
import { cn, themedBody } from "@/lib/utils";
|
|
34
|
+
|
|
35
|
+
// Mirrors nastech_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously
|
|
36
|
+
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
|
|
37
|
+
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
38
|
+
|
|
39
|
+
/** Braille unicode spinner (`unicode-animations`); static first frame when reduced motion is preferred. */
|
|
40
|
+
function ProfilesLoadingSpinner() {
|
|
41
|
+
const { frames, interval } = spinners.braille;
|
|
42
|
+
const [frameIndex, setFrameIndex] = useState(0);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (
|
|
46
|
+
typeof window !== "undefined" &&
|
|
47
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
48
|
+
) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const id = window.setInterval(
|
|
52
|
+
() => setFrameIndex((i) => (i + 1) % frames.length),
|
|
53
|
+
interval,
|
|
54
|
+
);
|
|
55
|
+
return () => window.clearInterval(id);
|
|
56
|
+
}, [frames.length, interval]);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<span
|
|
60
|
+
aria-hidden
|
|
61
|
+
className="inline-block select-none font-mono text-xl leading-none text-muted-foreground"
|
|
62
|
+
>
|
|
63
|
+
{frames[frameIndex]}
|
|
64
|
+
</span>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default function ProfilesPage() {
|
|
69
|
+
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
|
70
|
+
const [loading, setLoading] = useState(true);
|
|
71
|
+
const { toast, showToast } = useToast();
|
|
72
|
+
const { t } = useI18n();
|
|
73
|
+
const { setEnd } = usePageHeader();
|
|
74
|
+
|
|
75
|
+
// Create modal
|
|
76
|
+
const [createModalOpen, setCreateModalOpen] = useState(false);
|
|
77
|
+
const [newName, setNewName] = useState("");
|
|
78
|
+
const [cloneFromDefault, setCloneFromDefault] = useState(true);
|
|
79
|
+
const [creating, setCreating] = useState(false);
|
|
80
|
+
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
|
|
81
|
+
const createModalRef = useModalBehavior({
|
|
82
|
+
open: createModalOpen,
|
|
83
|
+
onClose: closeCreateModal,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Inline rename state
|
|
87
|
+
const [renamingFrom, setRenamingFrom] = useState<string | null>(null);
|
|
88
|
+
const [renameTo, setRenameTo] = useState("");
|
|
89
|
+
|
|
90
|
+
// Inline SOUL editor state
|
|
91
|
+
const [editingSoulFor, setEditingSoulFor] = useState<string | null>(null);
|
|
92
|
+
const [soulText, setSoulText] = useState("");
|
|
93
|
+
const [soulSaving, setSoulSaving] = useState(false);
|
|
94
|
+
// Tracks the latest SOUL request so out-of-order responses don't overwrite
|
|
95
|
+
// newer state when the user switches profiles or closes the editor.
|
|
96
|
+
const activeSoulRequest = useRef<string | null>(null);
|
|
97
|
+
|
|
98
|
+
const load = useCallback(() => {
|
|
99
|
+
api
|
|
100
|
+
.getProfiles()
|
|
101
|
+
.then((res) => setProfiles(res.profiles))
|
|
102
|
+
.catch((e) => showToast(`${t.status.error}: ${e}`, "error"))
|
|
103
|
+
.finally(() => setLoading(false));
|
|
104
|
+
}, [showToast, t.status.error]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
load();
|
|
108
|
+
}, [load]);
|
|
109
|
+
|
|
110
|
+
const handleCreate = async () => {
|
|
111
|
+
const name = newName.trim();
|
|
112
|
+
if (!name) {
|
|
113
|
+
showToast(t.profiles.nameRequired, "error");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!PROFILE_NAME_RE.test(name)) {
|
|
117
|
+
showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
setCreating(true);
|
|
121
|
+
try {
|
|
122
|
+
await api.createProfile({ name, clone_from_default: cloneFromDefault });
|
|
123
|
+
showToast(`${t.profiles.created}: ${name}`, "success");
|
|
124
|
+
setNewName("");
|
|
125
|
+
setCreateModalOpen(false);
|
|
126
|
+
load();
|
|
127
|
+
} catch (e) {
|
|
128
|
+
showToast(`${t.status.error}: ${e}`, "error");
|
|
129
|
+
} finally {
|
|
130
|
+
setCreating(false);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const handleRenameSubmit = async () => {
|
|
135
|
+
if (!renamingFrom) return;
|
|
136
|
+
const target = renameTo.trim();
|
|
137
|
+
if (!target || target === renamingFrom) {
|
|
138
|
+
setRenamingFrom(null);
|
|
139
|
+
setRenameTo("");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!PROFILE_NAME_RE.test(target)) {
|
|
143
|
+
showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
await api.renameProfile(renamingFrom, target);
|
|
148
|
+
showToast(
|
|
149
|
+
`${t.profiles.renamed}: ${renamingFrom} → ${target}`,
|
|
150
|
+
"success",
|
|
151
|
+
);
|
|
152
|
+
setRenamingFrom(null);
|
|
153
|
+
setRenameTo("");
|
|
154
|
+
load();
|
|
155
|
+
} catch (e) {
|
|
156
|
+
showToast(`${t.status.error}: ${e}`, "error");
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const openSoulEditor = useCallback(
|
|
161
|
+
async (name: string) => {
|
|
162
|
+
if (editingSoulFor === name) {
|
|
163
|
+
activeSoulRequest.current = null;
|
|
164
|
+
setEditingSoulFor(null);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
setEditingSoulFor(name);
|
|
168
|
+
setSoulText("");
|
|
169
|
+
activeSoulRequest.current = name;
|
|
170
|
+
try {
|
|
171
|
+
const soul = await api.getProfileSoul(name);
|
|
172
|
+
if (activeSoulRequest.current === name) {
|
|
173
|
+
setSoulText(soul.content);
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (activeSoulRequest.current === name) {
|
|
177
|
+
showToast(`${t.status.error}: ${e}`, "error");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
[editingSoulFor, showToast, t.status.error],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const handleSaveSoul = async (name: string) => {
|
|
185
|
+
setSoulSaving(true);
|
|
186
|
+
try {
|
|
187
|
+
await api.updateProfileSoul(name, soulText);
|
|
188
|
+
showToast(`${t.profiles.soulSaved}: ${name}`, "success");
|
|
189
|
+
} catch (e) {
|
|
190
|
+
showToast(`${t.status.error}: ${e}`, "error");
|
|
191
|
+
} finally {
|
|
192
|
+
setSoulSaving(false);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const handleCopyTerminalCommand = async (name: string) => {
|
|
197
|
+
let cmd: string;
|
|
198
|
+
try {
|
|
199
|
+
const res = await api.getProfileSetupCommand(name);
|
|
200
|
+
cmd = res.command;
|
|
201
|
+
} catch (e) {
|
|
202
|
+
showToast(`${t.status.error}: ${e}`, "error");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await navigator.clipboard.writeText(cmd);
|
|
207
|
+
showToast(`${t.profiles.commandCopied}: ${cmd}`, "success");
|
|
208
|
+
} catch {
|
|
209
|
+
showToast(`${t.profiles.copyFailed}: ${cmd}`, "error");
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const profileDelete = useConfirmDelete<string>({
|
|
214
|
+
onDelete: useCallback(
|
|
215
|
+
async (name: string) => {
|
|
216
|
+
try {
|
|
217
|
+
await api.deleteProfile(name);
|
|
218
|
+
showToast(`${t.profiles.deleted}: ${name}`, "success");
|
|
219
|
+
load();
|
|
220
|
+
} catch (e) {
|
|
221
|
+
showToast(`${t.status.error}: ${e}`, "error");
|
|
222
|
+
throw e;
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
[load, showToast, t.profiles.deleted, t.status.error],
|
|
226
|
+
),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const pendingName = profileDelete.pendingId;
|
|
230
|
+
|
|
231
|
+
// Put "Create" button in page header
|
|
232
|
+
useLayoutEffect(() => {
|
|
233
|
+
setEnd(
|
|
234
|
+
<Button
|
|
235
|
+
className="uppercase"
|
|
236
|
+
size="sm"
|
|
237
|
+
onClick={() => setCreateModalOpen(true)}
|
|
238
|
+
>
|
|
239
|
+
{t.common.create}
|
|
240
|
+
</Button>,
|
|
241
|
+
);
|
|
242
|
+
return () => {
|
|
243
|
+
setEnd(null);
|
|
244
|
+
};
|
|
245
|
+
}, [setEnd, t.common.create, loading]);
|
|
246
|
+
|
|
247
|
+
if (loading) {
|
|
248
|
+
return (
|
|
249
|
+
<div
|
|
250
|
+
aria-busy="true"
|
|
251
|
+
aria-live="polite"
|
|
252
|
+
className="flex items-center justify-center py-24"
|
|
253
|
+
>
|
|
254
|
+
<span className="sr-only">{t.common.loading}</span>
|
|
255
|
+
|
|
256
|
+
<ProfilesLoadingSpinner />
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<div className="flex flex-col gap-6">
|
|
263
|
+
<Toast toast={toast} />
|
|
264
|
+
|
|
265
|
+
<DeleteConfirmDialog
|
|
266
|
+
open={profileDelete.isOpen}
|
|
267
|
+
onCancel={profileDelete.cancel}
|
|
268
|
+
onConfirm={profileDelete.confirm}
|
|
269
|
+
title={t.profiles.confirmDeleteTitle}
|
|
270
|
+
description={
|
|
271
|
+
pendingName
|
|
272
|
+
? t.profiles.confirmDeleteMessage.replace("{name}", pendingName)
|
|
273
|
+
: t.profiles.confirmDeleteMessage
|
|
274
|
+
}
|
|
275
|
+
loading={profileDelete.isDeleting}
|
|
276
|
+
/>
|
|
277
|
+
|
|
278
|
+
{/* Create profile modal */}
|
|
279
|
+
{createModalOpen && (
|
|
280
|
+
<div
|
|
281
|
+
ref={createModalRef}
|
|
282
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
|
283
|
+
onClick={(e) =>
|
|
284
|
+
e.target === e.currentTarget && setCreateModalOpen(false)
|
|
285
|
+
}
|
|
286
|
+
role="dialog"
|
|
287
|
+
aria-modal="true"
|
|
288
|
+
aria-labelledby="create-profile-title"
|
|
289
|
+
>
|
|
290
|
+
<div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col")}>
|
|
291
|
+
<Button
|
|
292
|
+
ghost
|
|
293
|
+
size="icon"
|
|
294
|
+
onClick={() => setCreateModalOpen(false)}
|
|
295
|
+
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
|
296
|
+
aria-label="Close"
|
|
297
|
+
>
|
|
298
|
+
<X />
|
|
299
|
+
</Button>
|
|
300
|
+
|
|
301
|
+
<header className="p-5 pb-3 border-b border-border">
|
|
302
|
+
<h2
|
|
303
|
+
id="create-profile-title"
|
|
304
|
+
className="font-mondwest text-display text-base tracking-wider"
|
|
305
|
+
>
|
|
306
|
+
{t.profiles.newProfile}
|
|
307
|
+
</h2>
|
|
308
|
+
</header>
|
|
309
|
+
|
|
310
|
+
<div className="p-5 grid gap-4">
|
|
311
|
+
<div className="grid gap-2">
|
|
312
|
+
<Label htmlFor="profile-name">{t.profiles.name}</Label>
|
|
313
|
+
<Input
|
|
314
|
+
id="profile-name"
|
|
315
|
+
autoFocus
|
|
316
|
+
placeholder={t.profiles.namePlaceholder}
|
|
317
|
+
value={newName}
|
|
318
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
319
|
+
onKeyDown={(e) => {
|
|
320
|
+
if (e.key === "Enter") handleCreate();
|
|
321
|
+
}}
|
|
322
|
+
aria-invalid={
|
|
323
|
+
newName.trim() !== "" &&
|
|
324
|
+
!PROFILE_NAME_RE.test(newName.trim())
|
|
325
|
+
}
|
|
326
|
+
/>
|
|
327
|
+
<p className="text-xs text-muted-foreground">
|
|
328
|
+
{t.profiles.nameRule}
|
|
329
|
+
</p>
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
<div className="flex items-center gap-2.5">
|
|
333
|
+
<Checkbox
|
|
334
|
+
checked={cloneFromDefault}
|
|
335
|
+
id="clone-from-default"
|
|
336
|
+
onCheckedChange={(checked) =>
|
|
337
|
+
setCloneFromDefault(checked === true)
|
|
338
|
+
}
|
|
339
|
+
/>
|
|
340
|
+
|
|
341
|
+
<Label
|
|
342
|
+
className="font-mondwest normal-case tracking-normal text-sm cursor-pointer"
|
|
343
|
+
htmlFor="clone-from-default"
|
|
344
|
+
>
|
|
345
|
+
{t.profiles.cloneFromDefault}
|
|
346
|
+
</Label>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<div className="flex justify-end">
|
|
350
|
+
<Button
|
|
351
|
+
className="uppercase"
|
|
352
|
+
size="sm"
|
|
353
|
+
onClick={handleCreate}
|
|
354
|
+
disabled={creating}
|
|
355
|
+
>
|
|
356
|
+
{creating ? t.common.creating : t.common.create}
|
|
357
|
+
</Button>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
|
|
364
|
+
{/* List */}
|
|
365
|
+
<div className="flex flex-col gap-3">
|
|
366
|
+
<H2
|
|
367
|
+
variant="sm"
|
|
368
|
+
className="flex items-center gap-2 text-muted-foreground"
|
|
369
|
+
>
|
|
370
|
+
<Users className="h-4 w-4" />
|
|
371
|
+
{t.profiles.allProfiles} ({profiles.length})
|
|
372
|
+
</H2>
|
|
373
|
+
|
|
374
|
+
{profiles.length === 0 && (
|
|
375
|
+
<Card>
|
|
376
|
+
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
377
|
+
{t.profiles.noProfiles}
|
|
378
|
+
</CardContent>
|
|
379
|
+
</Card>
|
|
380
|
+
)}
|
|
381
|
+
|
|
382
|
+
{profiles.map((p) => {
|
|
383
|
+
const isRenaming = renamingFrom === p.name;
|
|
384
|
+
const isEditingSoul = editingSoulFor === p.name;
|
|
385
|
+
return (
|
|
386
|
+
<Card key={p.name}>
|
|
387
|
+
<CardContent className="flex items-start gap-4 py-4">
|
|
388
|
+
<div className="flex-1 min-w-0">
|
|
389
|
+
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
390
|
+
{isRenaming ? (
|
|
391
|
+
<Input
|
|
392
|
+
autoFocus
|
|
393
|
+
value={renameTo}
|
|
394
|
+
onChange={(e) => setRenameTo(e.target.value)}
|
|
395
|
+
onKeyDown={(e) => {
|
|
396
|
+
if (e.key === "Enter") handleRenameSubmit();
|
|
397
|
+
if (e.key === "Escape") setRenamingFrom(null);
|
|
398
|
+
}}
|
|
399
|
+
aria-invalid={
|
|
400
|
+
renameTo.trim() !== "" &&
|
|
401
|
+
renameTo.trim() !== p.name &&
|
|
402
|
+
!PROFILE_NAME_RE.test(renameTo.trim())
|
|
403
|
+
}
|
|
404
|
+
className="max-w-xs"
|
|
405
|
+
/>
|
|
406
|
+
) : (
|
|
407
|
+
<span className="font-medium text-sm truncate">
|
|
408
|
+
{p.name}
|
|
409
|
+
</span>
|
|
410
|
+
)}
|
|
411
|
+
{p.is_default && (
|
|
412
|
+
<Badge tone="secondary">{t.profiles.defaultBadge}</Badge>
|
|
413
|
+
)}
|
|
414
|
+
{p.has_env && (
|
|
415
|
+
<Badge tone="outline">{t.profiles.hasEnv}</Badge>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
{isRenaming &&
|
|
419
|
+
(() => {
|
|
420
|
+
const trimmed = renameTo.trim();
|
|
421
|
+
const invalid =
|
|
422
|
+
trimmed !== "" &&
|
|
423
|
+
trimmed !== p.name &&
|
|
424
|
+
!PROFILE_NAME_RE.test(trimmed);
|
|
425
|
+
return (
|
|
426
|
+
<p
|
|
427
|
+
className={
|
|
428
|
+
"text-xs mb-1 " +
|
|
429
|
+
(invalid
|
|
430
|
+
? "text-destructive"
|
|
431
|
+
: "text-muted-foreground")
|
|
432
|
+
}
|
|
433
|
+
>
|
|
434
|
+
{invalid
|
|
435
|
+
? `${t.profiles.invalidName}: ${t.profiles.nameRule}`
|
|
436
|
+
: t.profiles.nameRule}
|
|
437
|
+
</p>
|
|
438
|
+
);
|
|
439
|
+
})()}
|
|
440
|
+
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
|
|
441
|
+
{p.model && (
|
|
442
|
+
<span>
|
|
443
|
+
{t.profiles.model}: {p.model}
|
|
444
|
+
{p.provider ? ` (${p.provider})` : ""}
|
|
445
|
+
</span>
|
|
446
|
+
)}
|
|
447
|
+
<span>
|
|
448
|
+
{t.profiles.skills}: {p.skill_count}
|
|
449
|
+
</span>
|
|
450
|
+
<span className="font-mono truncate max-w-[28rem]">
|
|
451
|
+
{p.path}
|
|
452
|
+
</span>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
457
|
+
{isRenaming ? (
|
|
458
|
+
<>
|
|
459
|
+
<Button size="sm" onClick={handleRenameSubmit}>
|
|
460
|
+
{t.common.save}
|
|
461
|
+
</Button>
|
|
462
|
+
<Button
|
|
463
|
+
size="sm"
|
|
464
|
+
ghost
|
|
465
|
+
onClick={() => setRenamingFrom(null)}
|
|
466
|
+
>
|
|
467
|
+
{t.common.cancel}
|
|
468
|
+
</Button>
|
|
469
|
+
</>
|
|
470
|
+
) : (
|
|
471
|
+
<>
|
|
472
|
+
<Button
|
|
473
|
+
ghost
|
|
474
|
+
size="icon"
|
|
475
|
+
title={t.profiles.editSoul}
|
|
476
|
+
aria-label={t.profiles.editSoul}
|
|
477
|
+
onClick={() => openSoulEditor(p.name)}
|
|
478
|
+
>
|
|
479
|
+
{isEditingSoul ? (
|
|
480
|
+
<ChevronDown className="h-4 w-4" />
|
|
481
|
+
) : (
|
|
482
|
+
<span aria-hidden className="text-xs font-bold">
|
|
483
|
+
S
|
|
484
|
+
</span>
|
|
485
|
+
)}
|
|
486
|
+
</Button>
|
|
487
|
+
<Button
|
|
488
|
+
ghost
|
|
489
|
+
size="icon"
|
|
490
|
+
title={t.profiles.openInTerminal}
|
|
491
|
+
aria-label={t.profiles.openInTerminal}
|
|
492
|
+
onClick={() => handleCopyTerminalCommand(p.name)}
|
|
493
|
+
>
|
|
494
|
+
<Terminal className="h-4 w-4" />
|
|
495
|
+
</Button>
|
|
496
|
+
{!p.is_default && (
|
|
497
|
+
<Button
|
|
498
|
+
ghost
|
|
499
|
+
size="icon"
|
|
500
|
+
title={t.profiles.rename}
|
|
501
|
+
aria-label={t.profiles.rename}
|
|
502
|
+
onClick={() => {
|
|
503
|
+
setRenamingFrom(p.name);
|
|
504
|
+
setRenameTo(p.name);
|
|
505
|
+
}}
|
|
506
|
+
>
|
|
507
|
+
<Pencil className="h-4 w-4" />
|
|
508
|
+
</Button>
|
|
509
|
+
)}
|
|
510
|
+
{!p.is_default && (
|
|
511
|
+
<Button
|
|
512
|
+
ghost
|
|
513
|
+
size="icon"
|
|
514
|
+
title={t.common.delete}
|
|
515
|
+
aria-label={t.common.delete}
|
|
516
|
+
onClick={() => profileDelete.requestDelete(p.name)}
|
|
517
|
+
>
|
|
518
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
519
|
+
</Button>
|
|
520
|
+
)}
|
|
521
|
+
</>
|
|
522
|
+
)}
|
|
523
|
+
</div>
|
|
524
|
+
</CardContent>
|
|
525
|
+
|
|
526
|
+
{isEditingSoul && (
|
|
527
|
+
<div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2">
|
|
528
|
+
<Label
|
|
529
|
+
htmlFor={`soul-editor-${p.name}`}
|
|
530
|
+
className="flex items-center gap-2 font-mondwest text-display text-xs tracking-wider text-muted-foreground"
|
|
531
|
+
>
|
|
532
|
+
{t.profiles.soulSection}
|
|
533
|
+
</Label>
|
|
534
|
+
<textarea
|
|
535
|
+
id={`soul-editor-${p.name}`}
|
|
536
|
+
className="flex min-h-[180px] w-full border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
537
|
+
placeholder={t.profiles.soulPlaceholder}
|
|
538
|
+
value={soulText}
|
|
539
|
+
onChange={(e) => setSoulText(e.target.value)}
|
|
540
|
+
/>
|
|
541
|
+
<div>
|
|
542
|
+
<Button
|
|
543
|
+
size="sm"
|
|
544
|
+
className="uppercase"
|
|
545
|
+
onClick={() => handleSaveSoul(p.name)}
|
|
546
|
+
disabled={soulSaving}
|
|
547
|
+
>
|
|
548
|
+
{soulSaving ? t.common.saving : t.common.save}
|
|
549
|
+
</Button>
|
|
550
|
+
</div>
|
|
551
|
+
</div>
|
|
552
|
+
)}
|
|
553
|
+
</Card>
|
|
554
|
+
);
|
|
555
|
+
})}
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
);
|
|
559
|
+
}
|