@morlay/dsh-session-mode 0.0.14 → 0.0.16

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.
@@ -0,0 +1,803 @@
1
+ // 右栏面板:这个会话**选中的模式**与它当下生效的那一份。折叠组对齐上游右栏内容(20/24/40 内边距、整列滚动),
2
+ // 手风琴一次只开一组;工具 / 技能 / policy 用"默认 / 允许 / 禁止"三态切换(工具按组折叠、组内逐条);
3
+ // 每项标出来源(会话覆盖 / 模式默认),改动走会话级覆盖并立即重装收口。
4
+
5
+ import { useEffect, useState, type ReactNode } from "react";
6
+ import {
7
+ Button,
8
+ Input,
9
+ Menu,
10
+ Modal,
11
+ MultilineField,
12
+ Row,
13
+ SegmentedControl,
14
+ Stack,
15
+ Switch,
16
+ Text,
17
+ } from "@morlay/dsh-client-ui-primitives";
18
+ import type { PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
19
+ // Type-only:拉入本包 host 半的投影声明(`sessionMode`)。
20
+ import type {} from "../index.ts";
21
+ // Type-only:右栏 tab 座位的声明。
22
+ import type {} from "@deepseek-ai/dsh-client-ui-sidebar-right/client";
23
+ import { applyDraft, type ModeConfig } from "../mode-config.ts";
24
+ import { groupRef } from "../shared.ts";
25
+ import type {
26
+ SessionModeActiveState,
27
+ SessionModeDirectoryEntry,
28
+ SessionModeTriState,
29
+ } from "../shared.ts";
30
+ import {
31
+ deleteMode,
32
+ fetchActive,
33
+ saveAsNewMode,
34
+ applySessionConfig,
35
+ saveConfigDraft,
36
+ } from "./api.ts";
37
+
38
+ export type SessionModePanelProps = PropsRuntime<"sidebar.right.pane.tab"> &
39
+ PropsLocale<"session-mode">;
40
+
41
+ type Load =
42
+ | { readonly kind: "loading" }
43
+ | { readonly kind: "ready"; readonly value: SessionModeActiveState }
44
+ | { readonly kind: "error"; readonly message: string };
45
+
46
+ type SectionKey = "general" | "persona" | "tools" | "skills" | "policies";
47
+
48
+ // 面板草稿:本地改动先攒着,"应用到会话"时一次提交。
49
+ interface Draft {
50
+ switches: Record<string, boolean | null>;
51
+ // 三态编辑的是**配置值**:`allow` / `deny` / `default`(配置里不写这一项)。
52
+ items: Record<string, Record<string, "unset" | "allow" | "deny">>;
53
+ persona?: { prefix: string; suffix: string } | null;
54
+ }
55
+ // 三态之外还有"混合":组里各条目的判定不一致时,组行没有单一档位可选,只有它会出现,
56
+ // 且它自己不能再被选中——否则点"默认"会因为"已是当前值"而不回调,组里的禁止就清不掉。
57
+ type TriValue = "unset" | SessionModeTriState | "mixed";
58
+
59
+ // 上游右栏内容容器:整列滚动,20/24/40 的内边距。
60
+ const PANEL_STYLE = {
61
+ height: "100%",
62
+ overflowY: "auto",
63
+ boxSizing: "border-box",
64
+ padding: "20px 24px 40px",
65
+ } as const;
66
+
67
+ function Section({
68
+ title,
69
+ open,
70
+ onToggle,
71
+ children,
72
+ }: {
73
+ readonly title: string;
74
+ readonly open: boolean;
75
+ readonly onToggle: () => void;
76
+ readonly children: ReactNode;
77
+ }): ReactNode {
78
+ return (
79
+ <div
80
+ data-role="section"
81
+ data-section={title}
82
+ style={{ borderTop: "1px solid var(--dsw-alias-border-l1)" }}
83
+ >
84
+ <button
85
+ type="button"
86
+ data-role="section-heading"
87
+ onClick={onToggle}
88
+ style={{
89
+ width: "100%",
90
+ display: "flex",
91
+ alignItems: "center",
92
+ justifyContent: "space-between",
93
+ background: "none",
94
+ border: 0,
95
+ paddingBlock: 12,
96
+ paddingInline: 0,
97
+ font: "inherit",
98
+ fontSize: 13,
99
+ fontWeight: 500,
100
+ color: "inherit",
101
+ cursor: "pointer",
102
+ }}
103
+ >
104
+ <span>{title}</span>
105
+ <span
106
+ data-role="section-icon"
107
+ style={{
108
+ display: "inline-flex",
109
+ alignItems: "center",
110
+ justifyContent: "center",
111
+ width: 16,
112
+ height: 16,
113
+ fontSize: 14,
114
+ lineHeight: 1,
115
+ color: "var(--dsw-alias-label-tertiary)",
116
+ }}
117
+ >
118
+ {open ? "−" : "+"}
119
+ </span>
120
+ </button>
121
+ {open ? (
122
+ <div style={{ padding: "0 0 8px" }}>
123
+ <Stack gap={6}>{children}</Stack>
124
+ </div>
125
+ ) : null}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ function TriStateRow({
131
+ id,
132
+ description,
133
+ value,
134
+ indent = 0,
135
+ t,
136
+ onChange,
137
+ onLabelClick,
138
+ }: {
139
+ readonly id: string;
140
+ readonly description?: string;
141
+ readonly value: TriValue;
142
+ readonly indent?: number;
143
+ readonly t: SessionModePanelProps["t"];
144
+ readonly onChange: (next: TriValue) => void;
145
+ readonly onLabelClick?: () => void;
146
+ }): ReactNode {
147
+ return (
148
+ <div
149
+ data-role="tri-state"
150
+ data-id={id}
151
+ style={{
152
+ display: "flex",
153
+ flexWrap: "wrap",
154
+ alignItems: "center",
155
+ justifyContent: "space-between",
156
+ gap: 8,
157
+ padding: "6px 0",
158
+ paddingLeft: indent,
159
+ }}
160
+ >
161
+ <span
162
+ style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0, flex: "1 1 140px" }}
163
+ >
164
+ {onLabelClick === undefined ? (
165
+ <Text size="sm">{id}</Text>
166
+ ) : (
167
+ <button
168
+ type="button"
169
+ onClick={onLabelClick}
170
+ style={{
171
+ background: "none",
172
+ border: 0,
173
+ padding: 0,
174
+ font: "inherit",
175
+ fontSize: 13,
176
+ color: "inherit",
177
+ textAlign: "left",
178
+ cursor: "pointer",
179
+ }}
180
+ >
181
+ {id}
182
+ </button>
183
+ )}
184
+ {description === undefined || description.length === 0 ? null : (
185
+ <Text size="sm" tone="tertiary">
186
+ {description}
187
+ </Text>
188
+ )}
189
+ </span>
190
+ <div style={{ maxWidth: "100%", marginLeft: "auto" }}>
191
+ <SegmentedControl<"unset" | "allow" | "deny" | "mixed">
192
+ id={`tri-${id.replace(/[^a-zA-Z0-9_-]+/gu, "-")}`}
193
+ label={id}
194
+ value={value as "unset" | "allow" | "deny" | "mixed"}
195
+ options={
196
+ value === "mixed"
197
+ ? [
198
+ { value: "mixed", label: t("stateMixed") },
199
+ { value: "unset", label: t("stateDefault") },
200
+ { value: "allow", label: t("stateAllow") },
201
+ { value: "deny", label: t("stateDeny") },
202
+ ]
203
+ : [
204
+ { value: "unset", label: t("stateDefault") },
205
+ { value: "allow", label: t("stateAllow") },
206
+ { value: "deny", label: t("stateDeny") },
207
+ ]
208
+ }
209
+ onChange={(next) => {
210
+ if (next === "mixed") return;
211
+ onChange(next as TriValue);
212
+ }}
213
+ />
214
+ </div>
215
+ </div>
216
+ );
217
+ }
218
+
219
+ export function SessionModePanel({ sessionId, useSessions, t }: SessionModePanelProps) {
220
+ const mode = useSessions((state) => {
221
+ const value =
222
+ sessionId === undefined ? undefined : state.byId[sessionId]?.projectionValues?.sessionMode;
223
+ return typeof value === "string" ? value : undefined;
224
+ });
225
+ // preset 换了工具注册表也会换:把它一并作为重取的触发条件。
226
+ const preset = useSessions((state) => {
227
+ const value =
228
+ sessionId === undefined ? undefined : state.byId[sessionId]?.projectionValues?.agentPreset;
229
+ return typeof value === "string" ? value : undefined;
230
+ });
231
+ const [load, setLoad] = useState<Load>({ kind: "loading" });
232
+ const [notice, setNotice] = useState<string | null>(null);
233
+ // 草稿:本地改动先攒着,"应用到会话"时一次提交,避免每点一下都走一次请求 + 重装。
234
+ const [draft, setDraft] = useState<Draft>({
235
+ switches: {},
236
+ items: { tools: {}, skills: {}, policies: {} },
237
+ });
238
+ const [dialog, setDialog] = useState<"saveAs" | "delete" | null>(null);
239
+ const [menuOpen, setMenuOpen] = useState(false);
240
+ const [code, setCode] = useState("");
241
+ const [name, setName] = useState("");
242
+ const [openSection, setOpenSection] = useState<SectionKey | null>("general");
243
+ const [openGroups, setOpenGroups] = useState<ReadonlySet<string>>(new Set());
244
+
245
+ useEffect(() => {
246
+ if (sessionId === undefined) {
247
+ setLoad({ kind: "error", message: t("panelNoSession") });
248
+ return;
249
+ }
250
+ let cancelled = false;
251
+ setLoad({ kind: "loading" });
252
+ void fetchActive(sessionId)
253
+ .then((value) => {
254
+ if (!cancelled) setLoad({ kind: "ready", value });
255
+ })
256
+ .catch((error: unknown) => {
257
+ if (!cancelled) {
258
+ setLoad({
259
+ kind: "error",
260
+ message: error instanceof Error ? error.message : String(error),
261
+ });
262
+ }
263
+ });
264
+ return () => {
265
+ cancelled = true;
266
+ };
267
+ }, [sessionId, mode, preset]);
268
+
269
+ if (load.kind === "loading") {
270
+ return (
271
+ <div data-role="session-mode-panel" style={PANEL_STYLE}>
272
+ <Text size="sm" tone="tertiary">
273
+ {t("loading")}
274
+ </Text>
275
+ </div>
276
+ );
277
+ }
278
+ if (load.kind === "error") {
279
+ return (
280
+ <div data-role="session-mode-panel" style={PANEL_STYLE}>
281
+ <Text as="p" size="sm" tone="danger">
282
+ {load.message}
283
+ </Text>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ const active = load?.kind === "ready" ? load.value : undefined;
289
+ if (active === undefined || active === null) {
290
+ return (
291
+ <div data-role="session-mode-panel" style={PANEL_STYLE}>
292
+ <Text size="sm" tone="tertiary">
293
+ {t("loading")}
294
+ </Text>
295
+ </div>
296
+ );
297
+ }
298
+ // 有会话覆盖就是覆盖态;配置态与覆盖态都从 `config` / `session` 两份读。
299
+ const effective = active.session ?? active.config;
300
+ // 按字段的真实差异显示来源(host 给的清单);老 host 没有这个字段时退回"有覆盖就全标"。
301
+ const overridden = new Set<string>(
302
+ active.overriddenFields ??
303
+ (active.session === undefined ? [] : ["persona", "instructions", "skills", "runtimeContext"]),
304
+ );
305
+ const source = (field: string): string =>
306
+ overridden.has(field) ? t("fromSession") : t("fromMode");
307
+
308
+ // 配置态的 "default"(没写这一项)在 UI 里统一叫 unset。
309
+ // 组级 deny 优先显示:host 的 `fallback` 只看条目自己,组被禁时行也要显示禁止。
310
+ const configValue = (entry: SessionModeDirectoryEntry): "unset" | "allow" | "deny" => {
311
+ if (entry.group !== undefined && effective.denyTools.includes(groupRef(entry.group))) {
312
+ return "deny";
313
+ }
314
+ return entry.fallback === undefined || entry.fallback === "default" ? "unset" : entry.fallback;
315
+ };
316
+
317
+ // 草稿叠到配置上,得到"保存/应用后会变成"的那份 ModeConfig(与 host 用同一个纯函数)。
318
+ const viewConfig = (): ModeConfig =>
319
+ // 基座是**屏幕上看到的那份**(有覆盖就是覆盖):拿 `config` 会把已有覆盖整份丢掉。
320
+ applyDraft(
321
+ effective,
322
+ {
323
+ lists: {
324
+ tools: draft.items["tools"] ?? {},
325
+ skills: draft.items["skills"] ?? {},
326
+ policies: draft.items["policies"] ?? {},
327
+ },
328
+ ...(draft.persona === undefined ? {} : { persona: draft.persona }),
329
+ switches: draft.switches,
330
+ },
331
+ active.config,
332
+ );
333
+
334
+ // 同一个提交:应用 = 落到会话覆盖,保存 = 落到配置。两者用同一份 `ModeConfig`,只是目的地不同。
335
+ const commit = (target: "session" | "config"): void => {
336
+ if (sessionId === undefined) return;
337
+ const config = viewConfig();
338
+ const request =
339
+ target === "session"
340
+ ? applySessionConfig(sessionId, config)
341
+ : saveConfigDraft(sessionId, config);
342
+ void request
343
+ .then((value) => {
344
+ setLoad({ kind: "ready", value });
345
+ setDraft({ switches: {}, items: { tools: {}, skills: {}, policies: {} } });
346
+ setNotice(t(target === "session" ? "applied" : "saved"));
347
+ })
348
+ .catch((error: unknown) => setNotice(`${t("saveFailed")}: ${String(error)}`));
349
+ };
350
+
351
+ const toolGroups = new Map<string, SessionModeDirectoryEntry[]>();
352
+ for (const entry of active.catalog.tools) {
353
+ const group = entry.group ?? "其它";
354
+ const list = toolGroups.get(group);
355
+ if (list === undefined) toolGroups.set(group, [entry]);
356
+ else list.push(entry);
357
+ }
358
+
359
+ const toggleGroup = (group: string): void => {
360
+ setOpenGroups((current) => {
361
+ const next = new Set(current);
362
+ if (next.has(group)) next.delete(group);
363
+ else next.add(group);
364
+ return next;
365
+ });
366
+ };
367
+
368
+ const section = (key: SectionKey, title: string, children: ReactNode): ReactNode => (
369
+ <Section
370
+ title={title}
371
+ open={openSection === key}
372
+ onToggle={() => {
373
+ setOpenSection((current) => (current === key ? null : key));
374
+ }}
375
+ >
376
+ {children}
377
+ </Section>
378
+ );
379
+
380
+ return (
381
+ <div data-role="session-mode-panel" style={PANEL_STYLE}>
382
+ <div style={{ display: "flex", flexDirection: "column", gap: 48 }}>
383
+ <div data-role="header">
384
+ <Stack gap={4}>
385
+ <Row gap={6} align="center">
386
+ <Text size="lg">{active.mode.name}</Text>
387
+ </Row>
388
+ <Text size="sm" tone="tertiary">
389
+ {active.mode.description.length === 0 ? t("empty") : active.mode.description}
390
+ </Text>
391
+ </Stack>
392
+ </div>
393
+ <div style={{ height: 12 }} />
394
+
395
+ <div data-role="content">
396
+ {section(
397
+ "general",
398
+ t("general"),
399
+ <>
400
+ {(
401
+ [
402
+ ["instructions", t("instructions"), effective.instructions],
403
+ ["skills", t("skillsCatalog"), effective.skills],
404
+ ["runtimeContext", t("runtimeContext"), effective.runtimeContext],
405
+ ] as const
406
+ ).map(([key, label, value]) => (
407
+ <div
408
+ key={key}
409
+ style={{
410
+ display: "flex",
411
+ flexWrap: "wrap",
412
+ alignItems: "center",
413
+ justifyContent: "space-between",
414
+ gap: 8,
415
+ padding: "4px 0",
416
+ }}
417
+ >
418
+ <span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
419
+ <Text size="md">{label}</Text>
420
+ <Text size="sm" tone="tertiary">
421
+ {source(key)}
422
+ </Text>
423
+ </span>
424
+ <Row gap={6} style={{ marginLeft: "auto" }}>
425
+ {overridden.has(key) ? (
426
+ <Button
427
+ variant="outline"
428
+ size="sm"
429
+ onClick={() => {
430
+ setDraft((prev): Draft => ({
431
+ ...prev,
432
+ switches: { ...prev.switches, [key]: null },
433
+ }));
434
+ }}
435
+ >
436
+ {t("stateDefault")}
437
+ </Button>
438
+ ) : null}
439
+ <div>
440
+ <Switch
441
+ checked={draft.switches[key] ?? value}
442
+ label={label}
443
+ onChange={(next) => {
444
+ setDraft((prev): Draft => ({
445
+ ...prev,
446
+ switches: { ...prev.switches, [key]: next },
447
+ }));
448
+ }}
449
+ />
450
+ </div>
451
+ </Row>
452
+ </div>
453
+ ))}
454
+ </>,
455
+ )}
456
+
457
+ {section(
458
+ "persona",
459
+ t("persona"),
460
+ <>
461
+ <Text size="sm" tone="tertiary">
462
+ {source("persona")}
463
+ </Text>
464
+ <MultilineField
465
+ value={draft.persona?.prefix ?? effective.persona.prefix}
466
+ rows={6}
467
+ aria-label={t("persona")}
468
+ onChange={(event) => {
469
+ // 先取值:`setDraft` 的 updater 可能在事件派发之后才跑,那时 `currentTarget` 已是 null。
470
+ const prefix = event.currentTarget.value;
471
+ setDraft((prev): Draft => ({
472
+ ...prev,
473
+ persona: {
474
+ prefix,
475
+ suffix: prev.persona?.suffix ?? effective.persona.suffix,
476
+ },
477
+ }));
478
+ }}
479
+ />
480
+ <MultilineField
481
+ value={draft.persona?.suffix ?? effective.persona.suffix}
482
+ rows={3}
483
+ aria-label={t("persona")}
484
+ onChange={(event) => {
485
+ const suffix = event.currentTarget.value;
486
+ setDraft((prev): Draft => ({
487
+ ...prev,
488
+ persona: {
489
+ prefix: prev.persona?.prefix ?? effective.persona.prefix,
490
+ suffix,
491
+ },
492
+ }));
493
+ }}
494
+ />
495
+ </>,
496
+ )}
497
+
498
+ {section(
499
+ "tools",
500
+ `${t("tools")} (${String(active.catalog.tools.length)})`,
501
+ <>
502
+ {[...toolGroups].map(([group, entries]) => {
503
+ // 组引用草稿优先(组级三态写的就是这一条),否则按组内条目的一致性算。
504
+ const groupDraft = draft.items["tools"]?.[groupRef(group)];
505
+ const values =
506
+ groupDraft !== undefined
507
+ ? entries.map(() => groupDraft)
508
+ : entries.map(
509
+ (entry) =>
510
+ draft.items["tools"]?.[entry.id] ?? entry.override ?? configValue(entry),
511
+ );
512
+ const uniform = values.every((value) => value === values[0]);
513
+ const groupValue: TriValue = uniform ? (values[0] ?? "unset") : "mixed";
514
+ return (
515
+ <Stack key={group} gap={2}>
516
+ <TriStateRow
517
+ id={`${entries[0]?.groupLabel ?? group} (${String(entries.length)})`}
518
+ t={t}
519
+ value={groupValue}
520
+ onLabelClick={() => {
521
+ toggleGroup(group);
522
+ }}
523
+ onChange={(next) => {
524
+ const value = next === "allow" || next === "deny" ? next : "unset";
525
+ setDraft((prev): Draft => ({
526
+ ...prev,
527
+ items: {
528
+ ...prev.items,
529
+ tools: { ...prev.items["tools"], [groupRef(group)]: value },
530
+ },
531
+ }));
532
+ }}
533
+ />
534
+ {openGroups.has(group)
535
+ ? entries.map((entry) => (
536
+ <TriStateRow
537
+ key={entry.id}
538
+ id={entry.id}
539
+ description={entry.description}
540
+ indent={16}
541
+ t={t}
542
+ value={
543
+ draft.items["tools"]?.[entry.id] ??
544
+ entry.override ??
545
+ configValue(entry)
546
+ }
547
+ onChange={(next) => {
548
+ const value = next === "allow" || next === "deny" ? next : "unset";
549
+ setDraft((prev): Draft => ({
550
+ ...prev,
551
+ items: {
552
+ ...prev.items,
553
+ tools: { ...prev.items["tools"], [entry.id]: value },
554
+ },
555
+ }));
556
+ }}
557
+ />
558
+ ))
559
+ : null}
560
+ </Stack>
561
+ );
562
+ })}
563
+ </>,
564
+ )}
565
+
566
+ {section(
567
+ "skills",
568
+ `${t("skills")} (${String(active.catalog.skills.length)})`,
569
+ <>
570
+ {active.catalog.skills.map((entry) => (
571
+ <TriStateRow
572
+ key={entry.id}
573
+ id={entry.id}
574
+ description={entry.description}
575
+ t={t}
576
+ value={draft.items["skills"]?.[entry.id] ?? entry.override ?? configValue(entry)}
577
+ onChange={(next) => {
578
+ setDraft((prev): Draft => ({
579
+ ...prev,
580
+ items: {
581
+ ...prev.items,
582
+ skills: {
583
+ ...prev.items.skills,
584
+ [entry.id]: next === "allow" || next === "deny" ? next : "unset",
585
+ },
586
+ },
587
+ }));
588
+ }}
589
+ />
590
+ ))}
591
+ </>,
592
+ )}
593
+
594
+ {section(
595
+ "policies",
596
+ `${t("policies")} (${String(active.catalog.policies.length)})`,
597
+ <>
598
+ {active.catalog.policies.map((entry) => (
599
+ <TriStateRow
600
+ key={entry.id}
601
+ id={entry.id}
602
+ description={entry.description}
603
+ t={t}
604
+ value={
605
+ draft.items["policies"]?.[entry.id] ?? entry.override ?? configValue(entry)
606
+ }
607
+ onChange={(next) => {
608
+ setDraft((prev): Draft => ({
609
+ ...prev,
610
+ items: {
611
+ ...prev.items,
612
+ policies: {
613
+ ...prev.items.policies,
614
+ [entry.id]: next === "allow" || next === "deny" ? next : "unset",
615
+ },
616
+ },
617
+ }));
618
+ }}
619
+ />
620
+ ))}
621
+ </>,
622
+ )}
623
+ </div>
624
+ <div data-role="actions">
625
+ <Row gap={8} justify="end" align="center">
626
+ <Button
627
+ variant="outline"
628
+ onClick={() => {
629
+ setDraft({ switches: {}, items: { tools: {}, skills: {}, policies: {} } });
630
+ setNotice(null);
631
+ }}
632
+ >
633
+ {t("reset")}
634
+ </Button>
635
+ <Menu
636
+ open={menuOpen}
637
+ onClose={() => {
638
+ setMenuOpen(false);
639
+ }}
640
+ align="start"
641
+ portal
642
+ items={[
643
+ { id: "saveDefault", label: t("saveDefault") },
644
+ { id: "saveAs", label: t("saveAs") },
645
+ { id: "delete", label: t("delete"), disabled: !active.deletable },
646
+ ]}
647
+ onSelect={(id) => {
648
+ setMenuOpen(false);
649
+ if (id === "saveDefault") {
650
+ commit("config");
651
+ return;
652
+ }
653
+ if (id === "saveAs") {
654
+ setCode("");
655
+ setName(active.mode.name);
656
+ setDialog("saveAs");
657
+ return;
658
+ }
659
+ if (active.deletable) setDialog("delete");
660
+ }}
661
+ anchor={
662
+ <Row gap={0}>
663
+ <Button
664
+ variant="primary"
665
+ style={{
666
+ borderTopRightRadius: 0,
667
+ borderBottomRightRadius: 0,
668
+ borderRightWidth: 0,
669
+ }}
670
+ onClick={() => {
671
+ commit("session");
672
+ }}
673
+ >
674
+ {t("apply")}
675
+ </Button>
676
+ <Button
677
+ variant="primary"
678
+ aria-haspopup="menu"
679
+ aria-expanded={menuOpen}
680
+ style={{
681
+ borderTopLeftRadius: 0,
682
+ borderBottomLeftRadius: 0,
683
+ paddingInline: 8,
684
+ }}
685
+ onClick={() => {
686
+ setMenuOpen((current) => !current);
687
+ }}
688
+ >
689
+ ▾
690
+ </Button>
691
+ </Row>
692
+ }
693
+ />
694
+ </Row>
695
+ </div>
696
+ {notice === null ? null : (
697
+ <div data-role="notice" style={{ paddingTop: 8 }}>
698
+ <Text as="p" size="sm" tone="tertiary">
699
+ {notice}
700
+ </Text>
701
+ </div>
702
+ )}
703
+ </div>
704
+
705
+ <Modal
706
+ open={dialog === "saveAs"}
707
+ onClose={() => {
708
+ setDialog(null);
709
+ }}
710
+ title={t("saveAs")}
711
+ closeLabel={t("cancel")}
712
+ footer={
713
+ <Row gap={6}>
714
+ <Button
715
+ variant="outline"
716
+ size="sm"
717
+ onClick={() => {
718
+ setDialog(null);
719
+ }}
720
+ >
721
+ {t("cancel")}
722
+ </Button>
723
+ <Button
724
+ size="sm"
725
+ disabled={code.trim().length === 0}
726
+ onClick={() => {
727
+ if (sessionId === undefined || code.trim().length === 0) return;
728
+ void saveAsNewMode(sessionId, code, name)
729
+ .then(() => {
730
+ setDialog(null);
731
+ setNotice(t("savedNewMode"));
732
+ })
733
+ .catch((error: unknown) => setNotice(`${t("saveFailed")}: ${String(error)}`));
734
+ }}
735
+ >
736
+ {t("confirm")}
737
+ </Button>
738
+ </Row>
739
+ }
740
+ >
741
+ <Stack gap={6}>
742
+ <Input
743
+ value={code}
744
+ placeholder={t("codePlaceholder")}
745
+ aria-label={t("codeLabel")}
746
+ onChange={(event) => {
747
+ setCode(event.currentTarget.value);
748
+ }}
749
+ />
750
+ <Input
751
+ value={name}
752
+ placeholder={t("namePlaceholder")}
753
+ aria-label={t("nameLabel")}
754
+ onChange={(event) => {
755
+ setName(event.currentTarget.value);
756
+ }}
757
+ />
758
+ </Stack>
759
+ </Modal>
760
+
761
+ <Modal
762
+ open={dialog === "delete"}
763
+ onClose={() => {
764
+ setDialog(null);
765
+ }}
766
+ title={t("delete")}
767
+ closeLabel={t("cancel")}
768
+ description={t("deleteConfirm")}
769
+ footer={
770
+ <Row gap={6}>
771
+ <Button
772
+ variant="outline"
773
+ size="sm"
774
+ onClick={() => {
775
+ setDialog(null);
776
+ }}
777
+ >
778
+ {t("cancel")}
779
+ </Button>
780
+ <Button
781
+ size="sm"
782
+ onClick={() => {
783
+ if (sessionId === undefined) return;
784
+ void deleteMode(sessionId)
785
+ .then((next) => {
786
+ setDialog(null);
787
+ setLoad({ kind: "ready", value: next });
788
+ })
789
+ .catch((error: unknown) => setNotice(`${t("saveFailed")}: ${String(error)}`));
790
+ }}
791
+ >
792
+ {t("confirm")}
793
+ </Button>
794
+ </Row>
795
+ }
796
+ >
797
+ <Text as="p" size="sm">
798
+ {active.mode.name}
799
+ </Text>
800
+ </Modal>
801
+ </div>
802
+ );
803
+ }