@schlenkr/ragents 0.1.0 → 0.1.1

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 (94) hide show
  1. package/apps/server/src/plugin-support/language-server/contract.ts +6 -1
  2. package/apps/server/src/plugin-support/language-server/tools.ts +29 -4
  3. package/apps/server/src/plugin-support/run-scripts.ts +4 -1
  4. package/apps/server/src/plugin-support/start-entries-contract.ts +4 -3
  5. package/apps/server/src/plugin-support/workspace-sandbox-host.ts +2 -0
  6. package/apps/web/src/App.tsx +0 -10
  7. package/apps/web/src/PluginChat.tsx +8 -8
  8. package/apps/web/src/PluginRegistry.tsx +0 -12
  9. package/apps/web/src/StartSurface.tsx +2 -2
  10. package/apps/web/src/ThemeSettings.tsx +1 -1
  11. package/apps/web/src/column/ColumnApp.tsx +83 -7
  12. package/apps/web/src/column/clipboard.ts +64 -0
  13. package/apps/web/src/column/host-contract.ts +10 -0
  14. package/apps/web/src/column.tsx +2 -0
  15. package/apps/web/src/panel/LoginForm.tsx +9 -3
  16. package/apps/web/src/panel/OverviewPage.tsx +187 -62
  17. package/apps/web/src/panel/PanelDialogs.tsx +115 -0
  18. package/apps/web/src/panel/PanelPage.tsx +1 -1
  19. package/apps/web/src/panel/SettingsPage.tsx +75 -79
  20. package/apps/web/src/panel/contract.ts +17 -9
  21. package/apps/web/src/panel/target-state.ts +27 -2
  22. package/apps/web/src/ui/theme.css +4 -0
  23. package/package.json +2 -2
  24. package/packages/ragents/src/plugin-host.ts +5 -2
  25. package/packages/ragents/src/plugin-types.ts +3 -2
  26. package/packages/workspace-executor/src/context.ts +4 -0
  27. package/packages/workspace-executor/src/executor.ts +9 -2
  28. package/packages/workspace-executor/src/index.ts +2 -0
  29. package/packages/workspace-executor/src/language-server/adapters/fsharp.ts +1 -0
  30. package/packages/workspace-executor/src/language-server/adapters/roslyn.ts +1 -0
  31. package/packages/workspace-executor/src/language-server/adapters/typescript.ts +34 -6
  32. package/packages/workspace-executor/src/language-server/contract.ts +7 -3
  33. package/packages/workspace-executor/src/language-server/host.ts +275 -138
  34. package/plugins/ragents.actor-programs/contract.ts +1 -1
  35. package/plugins/ragents.actor-programs/prompt.hbs +4 -4
  36. package/plugins/ragents.actor-programs/server/app-project.ts +0 -2
  37. package/plugins/ragents.actor-programs/server/runtime.ts +1 -1
  38. package/plugins/ragents.actor-programs/web/AppsPanel.tsx +3 -5
  39. package/plugins/ragents.actor-programs/web/EmbeddedApps.tsx +7 -39
  40. package/plugins/ragents.actor-programs/web/api.ts +1 -10
  41. package/plugins/ragents.lsp-roslyn/web/language-server-plugin.tsx +73 -44
  42. package/plugins/ragents.orchestration/canvas.hbs +28 -44
  43. package/plugins/ragents.orchestration/contract.ts +17 -287
  44. package/plugins/ragents.orchestration/server/canvas-tool.ts +18 -123
  45. package/plugins/ragents.orchestration/tiled-layout.ts +12 -4
  46. package/plugins/ragents.orchestration/web/ActorCanvasControls.tsx +23 -47
  47. package/plugins/ragents.orchestration/web/ActorChatPreview.tsx +3 -9
  48. package/plugins/ragents.orchestration/web/ActorShortcut.tsx +3 -5
  49. package/plugins/ragents.orchestration/web/ActorTile.tsx +3 -2
  50. package/plugins/ragents.orchestration/web/CanvasTiles.tsx +3 -3
  51. package/plugins/ragents.orchestration/web/Center.tsx +28 -463
  52. package/plugins/ragents.orchestration/web/MaterialBody.tsx +6 -87
  53. package/plugins/ragents.orchestration/web/TiledCanvas.tsx +2 -3
  54. package/plugins/ragents.orchestration/web/canvas-view-settings.ts +7 -13
  55. package/plugins/ragents.orchestration/web/column/RunColumn.tsx +44 -19
  56. package/plugins/ragents.orchestration/web/column/column-state.ts +13 -4
  57. package/plugins/ragents.orchestration/web/constants.ts +0 -2
  58. package/plugins/ragents.orchestration/web/index.tsx +0 -6
  59. package/plugins/ragents.orchestration/web/tile-docking.ts +7 -1
  60. package/plugins/ragents.orchestration/web/tiled-view-settings.ts +7 -9
  61. package/plugins/ragents.overseer/server/coordinator-prompt.ts +1 -1
  62. package/plugins/ragents.reference/examples.ts +1 -2
  63. package/plugins/ragents.reference/run-scripts/balcony-wizard/RUN.md +1 -1
  64. package/plugins/ragents.reference/run-scripts/balcony-wizard/actors/balcony-app/package.json +1 -1
  65. package/plugins/ragents.reference/run-scripts/balcony-wizard/src/server.ts +1 -1
  66. package/plugins/ragents.reference/run-scripts/balcony-wizard/tests/program.test.ts +2 -2
  67. package/plugins/ragents.reference/run-scripts/conversation-circle/RUN.md +3 -4
  68. package/plugins/ragents.reference/run-scripts/conversation-circle/src/server.ts +9 -15
  69. package/plugins/ragents.reference/run-scripts/conversation-circle/tests/program.test.ts +5 -3
  70. package/plugins/ragents.reference/run-scripts/learning-afternoon/package.json +1 -1
  71. package/plugins/ragents.reference/run-scripts/learning-afternoon/src/server.ts +14 -4
  72. package/plugins/ragents.reference/run-scripts/word-game/src/server.ts +1 -1
  73. package/plugins/ragents.reference/skills/210-moderator-handover/SKILL.md +1 -1
  74. package/plugins/ragents.reference/walkthroughs.ts +14 -14
  75. package/plugins/ragents.workspace/client/workspace-client.ts +4 -1
  76. package/scripts/package/publish-package.ts +4 -5
  77. package/scripts/workspace-client/run-workspace-client.ts +3 -1
  78. package/apps/web/src/MaterialSettings.tsx +0 -40
  79. package/apps/web/src/material-settings.ts +0 -104
  80. package/plugins/ragents.orchestration/web/ActorNet.tsx +0 -607
  81. package/plugins/ragents.orchestration/web/CardSizeSettings.tsx +0 -42
  82. package/plugins/ragents.orchestration/web/ScriptActorCard.tsx +0 -56
  83. package/plugins/ragents.orchestration/web/ZoomSettings.tsx +0 -38
  84. package/plugins/ragents.orchestration/web/canvas-layout-storage.ts +0 -39
  85. package/plugins/ragents.orchestration/web/canvas-layout.ts +0 -326
  86. package/plugins/ragents.orchestration/web/canvas-scene.ts +0 -311
  87. package/plugins/ragents.orchestration/web/card-size-settings.ts +0 -36
  88. package/plugins/ragents.orchestration/web/keyboard-navigation.ts +0 -55
  89. package/plugins/ragents.orchestration/web/material-layout.ts +0 -14
  90. package/plugins/ragents.orchestration/web/useCanvasMeasurements.ts +0 -78
  91. package/plugins/ragents.orchestration/web/zoom-settings.ts +0 -28
  92. package/plugins/ragents.reference/skills/120-canvas-conversation-circle/SKILL.md +0 -12
  93. package/plugins/ragents.reference/skills/130-canvas-pipeline-with-approval/SKILL.md +0 -12
  94. package/plugins/ragents.reference/skills/140-canvas-team-tree-and-topic-grid/SKILL.md +0 -12
@@ -1,90 +1,215 @@
1
- import { BookIcon, CodeIcon, PlusIcon, SettingsIcon } from "lucide-react";
2
- import { Button, Card } from "../ui";
1
+ import { BookIcon, ChevronRightIcon, CodeIcon, PlusIcon, SearchIcon, ServerIcon, SettingsIcon } from "lucide-react";
2
+ import { useState, type CSSProperties, type ReactNode } from "react";
3
+ import { cn } from "cn";
4
+ import { Button, Input } from "../ui";
3
5
  import type { PanelAction, PanelState, TargetEntry, TargetRun, TargetView } from "./contract";
4
- import { LoginForm } from "./LoginForm";
5
- import { runStateLabel, runTone, stateDetail, stateLabel, stateTone } from "./target-state";
6
+ import { LoginDialog } from "./PanelDialogs";
7
+ import { busyState, environmentHue, kindLabel, runStateLabel, runTime, runTone, stateDetail, stateLabel, stateTone } from "./target-state";
6
8
 
7
9
  export interface PanelPageProps {
8
10
  readonly state: PanelState;
9
11
  readonly send: (action: PanelAction) => void;
10
12
  }
11
13
 
12
- const rowClass = "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring/60 focus-visible:-outline-offset-2 disabled:opacity-50";
14
+ const NEW_RUN_GROUP = "Ohne Vorlage";
15
+ const NEW_RUN_TEXT = "Leerer Run, der Auftrag entsteht im Chat der Arbeitsspalte.";
13
16
 
14
- function RunRow({ target, run, busy, send }: { target: TargetView; run: TargetRun; busy: boolean; send: PanelPageProps["send"] }) {
17
+ const edgeColor = "[border-left-color:oklch(var(--env-lightness)_var(--env-chroma)_var(--env-hue))]";
18
+ const sectionClass = "flex items-baseline gap-2 text-[0.66rem] font-bold uppercase tracking-[0.06em] text-muted-foreground";
19
+ const countClass = "font-mono text-[0.62rem] font-normal tracking-normal opacity-80";
20
+ const environmentLine = "truncate text-[0.62rem] leading-tight text-muted-foreground";
21
+
22
+ const hueStyle = (name: string): CSSProperties => ({ "--env-hue": environmentHue(name) } as CSSProperties);
23
+
24
+ function Section({ title, count }: { title: string; count: number }) {
25
+ return <h2 className={sectionClass}>{title}<span className={countClass}>{count}</span></h2>;
26
+ }
27
+
28
+ function EnvironmentRow({ target, marked, send, onLogin }: {
29
+ target: TargetView;
30
+ marked: boolean;
31
+ send: PanelPageProps["send"];
32
+ onLogin: (target: TargetView) => void;
33
+ }) {
34
+ const detail = stateDetail(target) ?? target.problem;
35
+ const busy = busyState(target);
36
+ return <li className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-border-soft px-1 py-2 last:border-b-0">
37
+ {marked && <span aria-hidden className="size-[9px] flex-none rounded-[3px] bg-[oklch(var(--env-lightness)_var(--env-chroma)_var(--env-hue))]" style={hueStyle(target.name)} />}
38
+ <strong className="min-w-0 flex-1 truncate text-[0.8rem] font-semibold">{target.name}</strong>
39
+ <span className="flex-none text-[0.62rem] uppercase tracking-[0.05em] text-muted-foreground">{kindLabel(target)}</span>
40
+ <span className={cn("flex flex-none items-center gap-1.5 text-[0.7rem]", stateTone(target))}>
41
+ <span aria-hidden className="size-[7px] flex-none rounded-full bg-current" />{stateLabel(target)}
42
+ </span>
43
+ <EnvironmentAction busy={busy} onLogin={onLogin} send={send} target={target} />
44
+ {detail && <p className="w-full text-[0.7rem] leading-normal text-destructive [overflow-wrap:anywhere]" role="alert">{detail}</p>}
45
+ </li>;
46
+ }
47
+
48
+ /** Nur eine Umgebung mit Problem bietet hier eine Handlung an; Trennen und Stoppen stehen in den Einstellungen. */
49
+ function EnvironmentAction({ target, busy, send, onLogin }: {
50
+ target: TargetView;
51
+ busy: boolean;
52
+ send: PanelPageProps["send"];
53
+ onLogin: (target: TargetView) => void;
54
+ }) {
55
+ const state = target.state.kind;
56
+ if (state === "login-required" || state === "forbidden") return <Button disabled={busy} onClick={() => onLogin(target)} size="xs" variant="secondary">Anmelden</Button>;
57
+ if (state === "unreachable" || state === "failed") return <Button disabled={busy} onClick={() => send({ action: "retry", name: target.name })} size="xs" variant="secondary">Erneut versuchen</Button>;
58
+ if (state === "stopped") return target.kind === "profile"
59
+ ? <Button disabled={busy} onClick={() => send({ action: "startProfile", name: target.name })} size="xs" variant="secondary">Starten</Button>
60
+ : <Button disabled={busy} onClick={() => send({ action: "connect", name: target.name })} size="xs" variant="secondary">Verbinden</Button>;
61
+ return null;
62
+ }
63
+
64
+ function RunRow({ target, run, marked, send }: { target: TargetView; run: TargetRun; marked: boolean; send: PanelPageProps["send"] }) {
15
65
  return <li>
16
- <button className={rowClass} disabled={busy} onClick={() => send({ action: "openRun", name: target.name, runId: run.id })} type="button">
17
- <span aria-hidden className={`size-[7px] flex-none rounded-full bg-current ${runTone(run)}`} />
18
- <span className="min-w-0 flex-1 truncate text-[0.82rem] font-medium">{run.title}</span>
19
- <span className={`flex-none text-[0.7rem] ${runTone(run)}`}>{runStateLabel(run)}</span>
66
+ <button
67
+ className={cn("flex w-full min-w-0 items-center gap-2 rounded-[2px_7px_7px_2px] border-l-[3px] border-transparent py-1.5 pr-2 pl-2 text-left hover:bg-accent focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring/60",
68
+ marked && cn("pl-[9px]", edgeColor))}
69
+ onClick={() => send({ action: "openRun", name: target.name, runId: run.id })}
70
+ style={marked ? hueStyle(target.name) : undefined}
71
+ title={marked ? `${run.title} (${target.name})` : run.title}
72
+ type="button">
73
+ <span aria-hidden className={cn("size-[7px] flex-none rounded-full bg-current", runTone(run))} />
74
+ <span className="flex min-w-0 flex-1 flex-col">
75
+ {marked && <span className={environmentLine}>{target.name}</span>}
76
+ <span className="truncate text-[0.8rem] font-medium">{run.title}</span>
77
+ </span>
78
+ <span className="flex flex-none flex-col items-end">
79
+ <span className={cn("text-[0.68rem]", runTone(run))}>{runStateLabel(run)}</span>
80
+ <span className="font-mono text-[0.62rem] text-muted-foreground">{runTime(run)}</span>
81
+ </span>
20
82
  </button>
21
83
  </li>;
22
84
  }
23
85
 
24
- function EntryRow({ target, entry, busy, send }: { target: TargetView; entry: TargetEntry; busy: boolean; send: PanelPageProps["send"] }) {
25
- return <li>
26
- <button className={rowClass} disabled={busy} onClick={() => send({ action: "newRun", name: target.name, entryId: entry.id })} title={entry.description} type="button">
27
- {entry.action === "skill" ? <BookIcon aria-hidden className="size-3.5 flex-none text-muted-foreground" /> : <CodeIcon aria-hidden className="size-3.5 flex-none text-muted-foreground" />}
28
- <span className="min-w-0 flex-1 truncate text-[0.82rem]">{entry.title}</span>
86
+ const tileClass = "group/tile flex h-full min-w-0 flex-col gap-1.5 border border-border-soft border-l-[3px] bg-card p-2.5 pl-[9px] text-left"
87
+ + " hover:border-border hover:bg-accent/40 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring/60";
88
+ const skillTone = "[--tone:var(--primary)] rounded-[3px_12px_12px_3px] border-l-primary/70";
89
+ const scriptTone = "[--tone:var(--success)] rounded-[3px_4px_4px_3px] border-l-success/70";
90
+
91
+ function Tile({ category, icon, title, description, environment, extra, onClick }: {
92
+ category: string;
93
+ icon: ReactNode;
94
+ title: string;
95
+ description: string;
96
+ environment: string | undefined;
97
+ extra: string;
98
+ onClick: () => void;
99
+ }) {
100
+ return <li className="min-w-0">
101
+ <button className={cn(tileClass, extra)} onClick={onClick} style={environment ? hueStyle(environment) : undefined}
102
+ title={environment ? `${title} (${environment})` : title} type="button">
103
+ <span className="flex min-w-0 items-center gap-1.5 text-(--tone)">
104
+ {icon}
105
+ <span className="truncate text-[0.58rem] font-semibold uppercase tracking-[0.06em] text-muted-foreground">{category}</span>
106
+ </span>
107
+ {environment && <span className={environmentLine}>{environment}</span>}
108
+ <span className="text-[0.76rem] font-semibold leading-snug [overflow-wrap:anywhere]">{title}</span>
109
+ <span className="line-clamp-2 text-[0.66rem] leading-[1.45] text-muted-foreground">{description}</span>
110
+ <span className="mt-auto flex items-center gap-0.5 pt-1.5 text-[0.62rem] font-semibold text-(--tone) opacity-50 group-hover/tile:opacity-100">
111
+ Starten<ChevronRightIcon aria-hidden className="size-3" />
112
+ </span>
29
113
  </button>
30
114
  </li>;
31
115
  }
32
116
 
33
- function TargetActions({ target, busy, send }: { target: TargetView; busy: boolean; send: PanelPageProps["send"] }) {
34
- const state = target.state;
35
- if (state.kind === "stopped") return <Button disabled={busy} onClick={() => send({ action: "connect", name: target.name })} size="sm" variant="secondary">{target.kind === "profile" ? "Starten" : "Verbinden"}</Button>;
36
- if (state.kind === "unreachable" || state.kind === "failed") return <Button disabled={busy} onClick={() => send({ action: "connect", name: target.name })} size="sm" variant="secondary">Erneut versuchen</Button>;
37
- if (state.kind === "connected") return <Button disabled={busy} onClick={() => send({ action: "disconnect", name: target.name })} size="sm" variant="ghost">{target.kind === "profile" ? "Stoppen" : "Trennen"}</Button>;
38
- return null;
117
+ interface Offer {
118
+ readonly target: TargetView;
119
+ readonly entry: TargetEntry | undefined;
39
120
  }
40
121
 
41
- function TargetCard({ target, send }: { target: TargetView; send: PanelPageProps["send"] }) {
42
- const detail = stateDetail(target);
43
- const connected = target.state.kind === "connected";
44
- const busy = target.state.kind === "starting" || target.state.kind === "connecting";
45
- return <Card className="gap-3 p-4">
46
- <header className="grid gap-1">
47
- <div className="flex min-w-0 items-center gap-2">
48
- <strong className="min-w-0 flex-1 truncate text-[0.95rem] font-semibold">{target.name}</strong>
49
- <span className={`flex-none text-[0.72rem] font-medium ${stateTone(target)}`}>{stateLabel(target)}</span>
50
- </div>
51
- <p className="truncate text-[0.72rem] text-muted-foreground" title={target.description}>{target.description}</p>
52
- </header>
53
- {detail && <p className="text-[0.75rem] leading-normal text-destructive [overflow-wrap:anywhere]" role="alert">{detail}</p>}
54
- {target.problem && <p className="text-[0.75rem] leading-normal text-destructive [overflow-wrap:anywhere]" role="alert">{target.problem}</p>}
55
- {target.state.kind === "login-required" && <LoginForm busy={busy} mode={target.state.mode} send={send} submitLabel="Anmelden" target={target} />}
56
- {connected && <>
57
- {target.runs.length > 0
58
- ? <ul className="-mx-2 grid gap-0.5">{target.runs.map((run) => <RunRow busy={busy} key={run.id} run={run} send={send} target={target} />)}</ul>
59
- : <p className="text-[0.75rem] text-muted-foreground">Noch keine Runs.</p>}
60
- {target.canCreate && <>
61
- <Button disabled={busy} onClick={() => send({ action: "newRun", name: target.name })} size="sm" variant="secondary"><PlusIcon data-icon="inline-start" />Neuer Run</Button>
62
- {target.entries.length > 0 && <details className="-mx-2">
63
- <summary className="cursor-pointer rounded-md px-2 py-1 text-[0.72rem] font-semibold text-muted-foreground hover:bg-accent">Startvorlagen <span className="font-mono opacity-80">{target.entries.length}</span></summary>
64
- <ul className="mt-1 grid gap-0.5">{target.entries.map((entry) => <EntryRow busy={busy} entry={entry} key={entry.id} send={send} target={target} />)}</ul>
65
- </details>}
66
- </>}
67
- </>}
68
- <footer className="flex flex-wrap items-center gap-2 empty:hidden">
69
- <TargetActions busy={busy} send={send} target={target} />
70
- {connected && target.user && <Button disabled={busy} onClick={() => send({ action: "logout", name: target.name })} size="sm" variant="ghost">Abmelden</Button>}
71
- </footer>
72
- </Card>;
122
+ const matches = (offer: Offer, query: string): boolean => {
123
+ const entry = offer.entry;
124
+ const haystack = entry === undefined
125
+ ? `${NEW_RUN_GROUP} Neuer Run ${NEW_RUN_TEXT} ${offer.target.name}`
126
+ : `${entry.title} ${entry.description} ${entry.category} ${offer.target.name}`;
127
+ return haystack.toLocaleLowerCase("de-DE").includes(query);
128
+ };
129
+
130
+ /** Die Kacheln stehen nach Kategorie gruppiert; die Gruppe "Ohne Vorlage" mit dem leeren Run bleibt oben. */
131
+ const groupOffers = (offers: readonly Offer[]): Array<readonly [string, readonly Offer[]]> => {
132
+ const groups = new Map<string, Offer[]>();
133
+ for (const offer of offers) {
134
+ const key = offer.entry?.category ?? NEW_RUN_GROUP;
135
+ const group = groups.get(key) ?? [];
136
+ group.push(offer);
137
+ groups.set(key, group);
138
+ }
139
+ return [...groups];
140
+ };
141
+
142
+ function Templates({ targets, marked, send }: { targets: readonly TargetView[]; marked: boolean; send: PanelPageProps["send"] }) {
143
+ const [query, setQuery] = useState("");
144
+ const offers: Offer[] = targets.flatMap((target) => [
145
+ { target, entry: undefined },
146
+ ...target.entries.map((entry) => ({ target, entry })),
147
+ ]);
148
+ const needle = query.trim().toLocaleLowerCase("de-DE");
149
+ const filtered = needle === "" ? offers : offers.filter((offer) => matches(offer, needle));
150
+ return <section className="grid gap-2">
151
+ <Section count={offers.length} title="Startvorlagen" />
152
+ <div className="relative">
153
+ <SearchIcon aria-hidden className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
154
+ <Input aria-label="Vorlagen suchen" className="h-8 pl-8 text-[0.76rem]" onChange={(event) => setQuery(event.target.value)}
155
+ placeholder="Vorlagen suchen ..." type="search" value={query} />
156
+ </div>
157
+ {filtered.length === 0
158
+ ? <p className="text-[0.75rem] text-muted-foreground" role="status">Keine passende Vorlage.</p>
159
+ : groupOffers(filtered).map(([group, entries]) => <div className="grid gap-1.5" key={group}>
160
+ <h3 className={sectionClass}>{group}<span className={countClass}>{entries.length}</span></h3>
161
+ <ul className="grid grid-cols-[repeat(auto-fill,minmax(182px,1fr))] gap-2">
162
+ {entries.map(({ target, entry }) => entry === undefined
163
+ ? <Tile category={NEW_RUN_GROUP} description={NEW_RUN_TEXT} environment={marked ? target.name : undefined}
164
+ extra="[--tone:var(--primary)] rounded-[3px_10px_10px_3px] border-dashed border-l-primary/50" icon={<PlusIcon aria-hidden className="size-3.5" />}
165
+ key={target.name} onClick={() => send({ action: "newRun", name: target.name })} title="Neuer Run" />
166
+ : <Tile category={entry.category} description={entry.description} environment={marked ? target.name : undefined}
167
+ extra={entry.kind === "skill" ? skillTone : scriptTone}
168
+ icon={entry.kind === "skill" ? <BookIcon aria-hidden className="size-3.5" /> : <CodeIcon aria-hidden className="size-3.5" />}
169
+ key={`${target.name}:${entry.id}`}
170
+ onClick={() => send({ action: "newRun", name: target.name, entryId: entry.id })} title={entry.title} />)}
171
+ </ul>
172
+ </div>)}
173
+ </section>;
73
174
  }
74
175
 
75
- /** Die Startseite des Panels: alle Ziele nebeneinander, je Ziel Zustand, Runs und Startvorlagen. */
176
+ /** Die Startseite des Panels: alle Umgebungen, darunter ihre Runs und ihre Startvorlagen als Kacheln. */
76
177
  export function OverviewPage({ state, send }: PanelPageProps) {
77
- return <div className="grid gap-3">
178
+ const [login, setLogin] = useState<string>();
179
+ const targets = state.targets;
180
+ const marked = targets.length > 1;
181
+ const runs = targets.flatMap((target) => target.runs.map((run) => ({ target, run })))
182
+ .sort((left, right) => right.run.updatedAt - left.run.updatedAt);
183
+ const starters = targets.filter((target) => target.state.kind === "connected" && target.canCreate);
184
+ const loginTarget = targets.find((target) => target.name === login);
185
+ return <div className="grid gap-4">
78
186
  <div className="flex items-center justify-between gap-2">
79
- <h1 className="text-[1.15rem] font-semibold">Übersicht</h1>
187
+ <h1 className="text-[1.1rem] font-semibold">Übersicht</h1>
80
188
  <Button aria-label="Einstellungen" onClick={() => send({ action: "page", page: "settings" })} size="icon-sm" title="Einstellungen" variant="ghost"><SettingsIcon /></Button>
81
189
  </div>
82
190
  {state.problem && <p className="text-[0.8rem] leading-normal text-destructive [overflow-wrap:anywhere]" role="alert">{state.problem}</p>}
83
- {state.targets.length === 0
84
- ? <Card className="gap-3 p-4">
85
- <p className="text-[0.85rem] leading-normal text-muted-foreground">Noch kein Ziel. Lege einen Server oder ein lokales Profil an.</p>
86
- <Button onClick={() => send({ action: "page", page: "settings" })}><PlusIcon data-icon="inline-start" />Ziel anlegen</Button>
87
- </Card>
88
- : state.targets.map((target) => <TargetCard key={target.name} send={send} target={target} />)}
191
+ {targets.length === 0
192
+ ? <div className="grid gap-3">
193
+ <p className="text-[0.85rem] leading-normal text-muted-foreground">Noch keine Umgebung. Lege einen Server oder ein lokales Profil an.</p>
194
+ <Button onClick={() => send({ action: "page", page: "settings" })}><ServerIcon data-icon="inline-start" />Umgebung anlegen</Button>
195
+ </div>
196
+ : <>
197
+ <section className="grid gap-1.5">
198
+ {marked && <Section count={targets.length} title="Umgebungen" />}
199
+ <ul className="grid">
200
+ {targets.map((target) => <EnvironmentRow key={target.name} marked={marked} onLogin={(entry) => setLogin(entry.name)} send={send} target={target} />)}
201
+ </ul>
202
+ </section>
203
+ <section className="grid gap-1.5">
204
+ <Section count={runs.length} title="Runs" />
205
+ {runs.length === 0
206
+ ? <p className="text-[0.75rem] text-muted-foreground">Noch keine Runs.</p>
207
+ : <ul className="-mx-1 grid gap-0.5">
208
+ {runs.map(({ target, run }) => <RunRow key={`${target.name}:${run.id}`} marked={marked} run={run} send={send} target={target} />)}
209
+ </ul>}
210
+ </section>
211
+ {starters.length > 0 && <Templates marked={marked} send={send} targets={starters} />}
212
+ </>}
213
+ {loginTarget && <LoginDialog onClose={() => setLogin(undefined)} send={send} target={loginTarget} />}
89
214
  </div>;
90
215
  }
@@ -0,0 +1,115 @@
1
+ import { FolderOpenIcon } from "lucide-react";
2
+ import { useEffect, useState, type FormEvent, type ReactNode } from "react";
3
+ import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input, Label, Toggle } from "../ui";
4
+ import type { PanelAction, PanelState, TargetKind, TargetView } from "./contract";
5
+ import { busyState } from "./target-state";
6
+ import { LoginForm } from "./LoginForm";
7
+
8
+ const PROFILE_FILE = /^ragents\.config\.([a-z0-9]+(?:-[a-z0-9]+)*)\.ts$/;
9
+
10
+ /** Eine Profildatei heißt ragents.config.<profil>.ts; ihr Profil ist der Name, den der Dialog vorschlägt. */
11
+ export const profileNameOf = (profileFile: string): string =>
12
+ PROFILE_FILE.exec(profileFile.split(/[/\\]/).pop() ?? "")?.[1] ?? "";
13
+
14
+ const fileNameOf = (profileFile: string): string => profileFile.split(/[/\\]/).pop() ?? profileFile;
15
+
16
+ function PanelDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
17
+ return <Dialog onOpenChange={(next) => { if (!next) onClose(); }} open>
18
+ <DialogContent className="gap-3" scope="page" size="small">
19
+ <DialogHeader><DialogTitle className="text-[0.95rem] font-semibold">{title}</DialogTitle></DialogHeader>
20
+ {children}
21
+ </DialogContent>
22
+ </Dialog>;
23
+ }
24
+
25
+ /** Die Anmeldung einer Umgebung, aus der Übersicht wie aus den Einstellungen derselbe Dialog. */
26
+ export function LoginDialog({ target, onClose, send }: { target: TargetView; onClose: () => void; send: (action: PanelAction) => void }) {
27
+ const demanded = target.state.kind === "login-required" ? target.state.mode : "password";
28
+ const [mode, setMode] = useState<"password" | "token">(demanded);
29
+ useEffect(() => { if (target.state.kind === "connected") onClose(); }, [onClose, target.state.kind]);
30
+ return <PanelDialog onClose={onClose} title={`Anmelden an ${target.name}`}>
31
+ <div aria-label="Art der Anmeldung" className="flex gap-1.5" role="group">
32
+ <Toggle onPressedChange={() => setMode("password")} pressed={mode === "password"} size="sm" variant="outline">Benutzer</Toggle>
33
+ <Toggle onPressedChange={() => setMode("token")} pressed={mode === "token"} size="sm" variant="outline">Zugangstoken</Toggle>
34
+ </div>
35
+ <LoginForm busy={busyState(target)} key={mode} mode={mode} onCancel={onClose} send={send} submitLabel="Anmelden" target={target} />
36
+ </PanelDialog>;
37
+ }
38
+
39
+ /** Anlegen und Bearbeiten einer Umgebung; Bearbeiten schickt update und behält damit die gespeicherten Anmeldedaten. */
40
+ export function TargetDialog({ state, target, onClose, send }: {
41
+ state: PanelState;
42
+ target: TargetView | undefined;
43
+ onClose: () => void;
44
+ send: (action: PanelAction) => void;
45
+ }) {
46
+ const [kind, setKind] = useState<TargetKind>(target?.kind ?? "server");
47
+ const [name, setName] = useState(target?.name ?? "");
48
+ const [url, setUrl] = useState(target?.kind === "server" ? target.address : "");
49
+ const [profileFile, setProfileFile] = useState(target?.kind === "profile" ? target.address : "");
50
+ const [sent, setSent] = useState<{ name: string; at: PanelState }>();
51
+ const picked = state.pickedProfileFile;
52
+ const settled = sent !== undefined && sent.at !== state;
53
+ useEffect(() => {
54
+ if (!picked) return;
55
+ setKind("profile");
56
+ setProfileFile(picked);
57
+ setName((current) => current.trim() === "" ? profileNameOf(picked) : current);
58
+ }, [picked]);
59
+ useEffect(() => {
60
+ if (!settled || sent === undefined) return;
61
+ if (state.problem !== undefined) setSent(undefined);
62
+ else if (state.targets.some((entry) => entry.name === sent.name)) onClose();
63
+ }, [onClose, sent, settled, state]);
64
+ const takeProfile = (file: string) => {
65
+ setProfileFile(file);
66
+ setName((current) => current.trim() === "" ? profileNameOf(file) : current);
67
+ };
68
+ const value = kind === "server" ? url : profileFile;
69
+ const submit = (event: FormEvent) => {
70
+ event.preventDefault();
71
+ setSent({ name: name.trim(), at: state });
72
+ if (target === undefined) send(kind === "server" ? { action: "addServer", name, url } : { action: "addProfile", name, profileFile });
73
+ else send(kind === "server"
74
+ ? { action: "updateServer", name: target.name, newName: name, url }
75
+ : { action: "updateProfile", name: target.name, newName: name, profileFile });
76
+ };
77
+ return <PanelDialog onClose={onClose} title={target ? `${target.name} bearbeiten` : "Neue Umgebung"}>
78
+ <form className="grid gap-3" onSubmit={submit}>
79
+ <div aria-label="Art der Umgebung" className="flex gap-1.5" role="group">
80
+ <Toggle onPressedChange={() => setKind("server")} pressed={kind === "server"} size="sm" variant="outline">Server</Toggle>
81
+ <Toggle onPressedChange={() => setKind("profile")} pressed={kind === "profile"} size="sm" variant="outline">Lokales Profil</Toggle>
82
+ </div>
83
+ {kind === "server"
84
+ ? <div className="grid gap-1.5"><Label htmlFor="target-url">Adresse</Label><Input id="target-url" onChange={(event) => setUrl(event.target.value)} placeholder="https://werkstatt.example.com" value={url} /></div>
85
+ : <div className="grid gap-1.5">
86
+ <Label htmlFor="target-profile-file">Profildatei</Label>
87
+ <div className="flex items-center gap-2">
88
+ <Input aria-label="Profildatei" className="min-w-0 flex-1 font-mono text-[0.72rem]" id="target-profile-file" onChange={(event) => setProfileFile(event.target.value)} placeholder="~/repos/RAgents/ragents.config.core.ts" title={profileFile} value={profileFile} />
89
+ <Button className="flex-none" onClick={() => send({ action: "pickProfile" })} size="sm" type="button" variant="secondary"><FolderOpenIcon data-icon="inline-start" />Datei wählen ...</Button>
90
+ </div>
91
+ {state.profileSuggestions.length > 0 && <>
92
+ <p className="text-[0.7rem] leading-normal text-muted-foreground">Im Host-Ordner gefunden, ein Klick übernimmt sie:</p>
93
+ <ul className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-1.5">
94
+ {state.profileSuggestions.map((file) => <li key={file}>
95
+ <button aria-pressed={profileFile === file} className="flex w-full min-w-0 flex-col gap-0.5 rounded-md border border-border-soft px-2.5 py-1.5 text-left hover:border-border aria-pressed:border-primary aria-pressed:bg-accent" onClick={() => takeProfile(file)} type="button">
96
+ <strong className="truncate text-[0.75rem] font-semibold">{profileNameOf(file)}</strong>
97
+ <span className="truncate font-mono text-[0.65rem] text-muted-foreground" title={file}>{fileNameOf(file)}</span>
98
+ </button>
99
+ </li>)}
100
+ </ul>
101
+ </>}
102
+ </div>}
103
+ <div className="grid gap-1.5">
104
+ <Label htmlFor="target-name">Name</Label>
105
+ <Input id="target-name" onChange={(event) => setName(event.target.value)} placeholder={kind === "server" ? "Werkstatt" : "core"} value={name} />
106
+ {kind === "profile" && <span className="text-[0.68rem] text-muted-foreground">aus dem Dateinamen übernommen, überschreibbar</span>}
107
+ </div>
108
+ {state.problem && <p className="text-[0.75rem] leading-normal text-destructive [overflow-wrap:anywhere]" role="alert">{state.problem}</p>}
109
+ <div className="mt-1 flex flex-wrap justify-end gap-2">
110
+ <Button onClick={onClose} size="sm" type="button" variant="ghost">Abbrechen</Button>
111
+ <Button disabled={!name.trim() || !value.trim()} size="sm" type="submit">{target ? "Speichern" : "Anlegen"}</Button>
112
+ </div>
113
+ </form>
114
+ </PanelDialog>;
115
+ }
@@ -1,7 +1,7 @@
1
1
  import { OverviewPage, type PanelPageProps } from "./OverviewPage";
2
2
  import { SettingsPage } from "./SettingsPage";
3
3
 
4
- /** Das Panel der Erweiterung ohne geöffneten Run: die Übersicht aller Ziele, dahinter die Einstellungsseite. */
4
+ /** Das Panel der Erweiterung ohne geöffneten Run: die Übersicht aller Umgebungen, dahinter die Einstellungsseite. */
5
5
  export function PanelPage(props: PanelPageProps) {
6
6
  return <main className="min-h-dvh p-3">
7
7
  {props.state.page === "settings" ? <SettingsPage {...props} /> : <OverviewPage {...props} />}
@@ -1,102 +1,98 @@
1
- import { ArrowLeftIcon, FolderOpenIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";
2
- import { useEffect, useState, type FormEvent } from "react";
3
- import { Button, Card, Input, Label, Toggle } from "../ui";
1
+ import { ArrowLeftIcon, FolderIcon, PencilIcon, PlusIcon, ServerIcon, SettingsIcon, Trash2Icon } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { cn } from "cn";
4
+ import { Button } from "../ui";
4
5
  import type { PanelAction, TargetView } from "./contract";
5
- import { LoginForm } from "./LoginForm";
6
6
  import type { PanelPageProps } from "./OverviewPage";
7
- import { stateLabel, stateTone } from "./target-state";
7
+ import { LoginDialog, TargetDialog } from "./PanelDialogs";
8
+ import { busyState, kindLabel, stateDetail, stateLabel, stateTone } from "./target-state";
8
9
 
9
- const sectionTitleClass = "text-[0.8rem] font-semibold";
10
+ const fileNameOf = (address: string): string => address.split(/[/\\]/).pop() ?? address;
10
11
 
11
- function Credentials({ target, busy, send }: { target: TargetView; busy: boolean; send: (action: PanelAction) => void }) {
12
- const demanded = target.state.kind === "login-required" ? target.state.mode : "password";
13
- const [mode, setMode] = useState<"password" | "token">(demanded);
14
- return <details className="rounded-md border border-border-soft px-2.5 py-2">
15
- <summary className="cursor-pointer text-[0.75rem] font-semibold text-muted-foreground">Anmeldedaten {target.savedLogin ? "(gespeichert)" : ""}</summary>
16
- <div className="mt-2.5 grid gap-2.5">
17
- <div className="flex gap-1.5">
18
- <Toggle onPressedChange={() => setMode("password")} pressed={mode === "password"} size="sm" variant="outline">Benutzer</Toggle>
19
- <Toggle onPressedChange={() => setMode("token")} pressed={mode === "token"} size="sm" variant="outline">Zugangstoken</Toggle>
20
- </div>
21
- <LoginForm busy={busy} mode={mode} send={send} submitLabel="Speichern und anmelden" target={target} />
22
- {target.savedLogin && <Button disabled={busy} onClick={() => send({ action: "logout", name: target.name })} size="sm" variant="ghost">Anmeldedaten löschen</Button>}
23
- </div>
24
- </details>;
12
+ /** Verbinden, Trennen, Starten und Stoppen sitzen hier; die Übersicht bietet nur den Weg aus einem Problem heraus. */
13
+ function RowActions({ target, busy, send, onLogin }: {
14
+ target: TargetView;
15
+ busy: boolean;
16
+ send: (action: PanelAction) => void;
17
+ onLogin: (name: string) => void;
18
+ }) {
19
+ const state = target.state.kind;
20
+ const profile = target.kind === "profile";
21
+ if (state === "connected") return <Button disabled={busy} onClick={() => send({ action: profile ? "stopProfile" : "disconnect", name: target.name })} size="xs" variant="ghost">{profile ? "Stoppen" : "Trennen"}</Button>;
22
+ if (state === "login-required" || state === "forbidden") return <Button disabled={busy} onClick={() => onLogin(target.name)} size="xs" variant="secondary">Anmelden</Button>;
23
+ if (state === "unreachable" || state === "failed") return <Button disabled={busy} onClick={() => send({ action: "retry", name: target.name })} size="xs" variant="secondary">Erneut versuchen</Button>;
24
+ if (state === "stopped") return <Button disabled={busy} onClick={() => send({ action: profile ? "startProfile" : "connect", name: target.name })} size="xs" variant="secondary">{profile ? "Starten" : "Verbinden"}</Button>;
25
+ return null;
25
26
  }
26
27
 
27
- function TargetRow({ target, send }: { target: TargetView; send: (action: PanelAction) => void }) {
28
+ function TargetRow({ target, send, onEdit, onLogin }: {
29
+ target: TargetView;
30
+ send: (action: PanelAction) => void;
31
+ onEdit: (name: string) => void;
32
+ onLogin: (name: string) => void;
33
+ }) {
28
34
  const [confirming, setConfirming] = useState(false);
29
- const busy = target.state.kind === "starting" || target.state.kind === "connecting";
30
- return <Card className="gap-2.5 p-3.5">
31
- <div className="flex min-w-0 items-start gap-2">
32
- <span className="min-w-0 flex-1">
33
- <strong className="block truncate text-[0.9rem] font-semibold">{target.name}</strong>
34
- <span className="block truncate text-[0.72rem] text-muted-foreground" title={target.description}>{target.description}</span>
35
- <span className={`block text-[0.7rem] ${stateTone(target)}`}>{stateLabel(target)}</span>
35
+ const busy = busyState(target);
36
+ const detail = stateDetail(target) ?? target.problem;
37
+ const profile = target.kind === "profile";
38
+ return <li className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2.5 gap-y-1.5 border-b border-border-soft px-1 py-2.5 last:border-b-0">
39
+ <span aria-hidden className="grid size-[26px] flex-none place-items-center rounded-md bg-secondary text-muted-foreground">
40
+ {profile ? <FolderIcon className="size-3.5" /> : <ServerIcon className="size-3.5" />}
41
+ </span>
42
+ <span className="min-w-0">
43
+ <span className="flex min-w-0 items-baseline gap-2">
44
+ <strong className="min-w-0 truncate text-[0.8rem] font-semibold">{target.name}</strong>
45
+ <span className="flex-none text-[0.6rem] uppercase tracking-[0.05em] text-muted-foreground">{kindLabel(target)}</span>
36
46
  </span>
47
+ <span className="block truncate font-mono text-[0.68rem] text-muted-foreground" title={target.address}>{profile ? fileNameOf(target.address) : target.address}</span>
48
+ </span>
49
+ <span className={cn("col-span-2 flex items-center gap-1.5 text-[0.7rem]", stateTone(target))}>
50
+ <span aria-hidden className="size-[7px] flex-none rounded-full bg-current" />{stateLabel(target)}
51
+ </span>
52
+ {detail && <p className="col-span-2 text-[0.7rem] leading-normal text-destructive [overflow-wrap:anywhere]" role="alert">{detail}</p>}
53
+ <span className="col-span-2 flex flex-wrap items-center gap-1.5">
54
+ <RowActions busy={busy} onLogin={onLogin} send={send} target={target} />
55
+ {target.savedLogin && <Button disabled={busy} onClick={() => send({ action: "logout", name: target.name })} size="xs" variant="ghost">Abmelden</Button>}
56
+ <Button disabled={busy} onClick={() => onEdit(target.name)} size="xs" variant="ghost"><PencilIcon data-icon="inline-start" />Bearbeiten</Button>
37
57
  {confirming
38
- ? <span className="flex flex-none items-center gap-1">
58
+ ? <span className="flex items-center gap-1.5">
59
+ <span className="text-[0.7rem] text-muted-foreground">Wirklich entfernen?</span>
39
60
  <Button disabled={busy} onClick={() => send({ action: "remove", name: target.name })} size="xs" variant="destructive">Entfernen</Button>
40
61
  <Button onClick={() => setConfirming(false)} size="xs" variant="ghost">Nein</Button>
41
62
  </span>
42
- : <Button aria-label={`${target.name} entfernen`} disabled={busy} onClick={() => setConfirming(true)} size="icon-sm" variant="ghost"><Trash2Icon /></Button>}
43
- </div>
44
- {target.problem && <p className="text-[0.75rem] leading-normal text-destructive [overflow-wrap:anywhere]" role="alert">{target.problem}</p>}
45
- {target.kind === "server" && <Credentials busy={busy} send={send} target={target} />}
46
- </Card>;
47
- }
48
-
49
- function AddServer({ send }: { send: (action: PanelAction) => void }) {
50
- const [name, setName] = useState("");
51
- const [url, setUrl] = useState("");
52
- const submit = (event: FormEvent) => {
53
- event.preventDefault();
54
- send({ action: "addServer", name, url });
55
- setName("");
56
- setUrl("");
57
- };
58
- return <form className="grid gap-2.5" onSubmit={submit}>
59
- <h2 className={sectionTitleClass}>Server hinzufügen</h2>
60
- <div className="grid gap-1.5"><Label htmlFor="server-name">Name</Label><Input id="server-name" onChange={(event) => setName(event.target.value)} placeholder="Werkstatt" value={name} /></div>
61
- <div className="grid gap-1.5"><Label htmlFor="server-url">Adresse</Label><Input id="server-url" onChange={(event) => setUrl(event.target.value)} placeholder="https://werkstatt.example.com" value={url} /></div>
62
- <Button disabled={!name.trim() || !url.trim()} size="sm" type="submit"><PlusIcon data-icon="inline-start" />Server anlegen</Button>
63
- </form>;
63
+ : <Button aria-label={`${target.name} entfernen`} className="ml-auto" disabled={busy} onClick={() => setConfirming(true)} size="icon-sm" variant="ghost"><Trash2Icon /></Button>}
64
+ </span>
65
+ </li>;
64
66
  }
65
67
 
66
- function AddProfile({ picked, send }: { picked: string | undefined; send: (action: PanelAction) => void }) {
67
- const [name, setName] = useState("");
68
- const [profileFile, setProfileFile] = useState("");
69
- useEffect(() => { if (picked) setProfileFile(picked); }, [picked]);
70
- const submit = (event: FormEvent) => {
71
- event.preventDefault();
72
- send({ action: "addProfile", name, profileFile });
73
- setName("");
74
- setProfileFile("");
75
- };
76
- return <form className="grid gap-2.5" onSubmit={submit}>
77
- <h2 className={sectionTitleClass}>Lokales Profil hinzufügen</h2>
78
- <div className="grid gap-1.5"><Label htmlFor="profile-name">Name</Label><Input id="profile-name" onChange={(event) => setName(event.target.value)} placeholder="core" value={name} /></div>
79
- <div className="grid gap-1.5"><Label htmlFor="profile-file">Profildatei</Label><Input id="profile-file" onChange={(event) => setProfileFile(event.target.value)} placeholder="~/repos/RAgents/ragents.config.core.ts" value={profileFile} /></div>
80
- <div className="flex flex-wrap gap-2">
81
- <Button onClick={() => send({ action: "pickProfile" })} size="sm" type="button" variant="ghost"><FolderOpenIcon data-icon="inline-start" />Datei wählen ...</Button>
82
- <Button className="flex-1" disabled={!name.trim() || !profileFile.trim()} size="sm" type="submit"><PlusIcon data-icon="inline-start" />Profil anlegen</Button>
83
- </div>
84
- </form>;
85
- }
86
-
87
- /** Eine Seite für alles, was eingerichtet wird: Ziele anlegen und entfernen, Anmeldedaten je Server. */
68
+ /** Alles, was eingerichtet wird: die Umgebungen als Zeilenliste, dahinter die Dialoge zum Anlegen, Bearbeiten und Anmelden. */
88
69
  export function SettingsPage({ state, send }: PanelPageProps) {
70
+ const [dialog, setDialog] = useState<{ kind: "new" } | { kind: "edit"; name: string }>();
71
+ const [login, setLogin] = useState<string>();
72
+ const editing = dialog?.kind === "edit" ? state.targets.find((target) => target.name === dialog.name) : undefined;
73
+ const loginTarget = state.targets.find((target) => target.name === login);
74
+ const open = dialog?.kind === "new" || editing !== undefined;
89
75
  return <div className="grid gap-3">
90
76
  <div className="flex items-center gap-2">
91
77
  <Button aria-label="Zur Übersicht" onClick={() => send({ action: "page", page: "overview" })} size="icon-sm" title="Zur Übersicht" variant="ghost"><ArrowLeftIcon /></Button>
92
- <h1 className="min-w-0 flex-1 text-[1.15rem] font-semibold">Einstellungen</h1>
78
+ <h1 className="min-w-0 flex-1 text-[1.1rem] font-semibold">Einstellungen</h1>
93
79
  <Button aria-label="Einstellung öffnen" onClick={() => send({ action: "settingsFile" })} size="icon-sm" title="Einstellung öffnen (settings.json)" variant="ghost"><SettingsIcon /></Button>
94
80
  </div>
95
81
  {state.problem && <p className="text-[0.8rem] leading-normal text-destructive [overflow-wrap:anywhere]" role="alert">{state.problem}</p>}
96
- {state.targets.length === 0
97
- ? <p className="text-[0.85rem] leading-normal text-muted-foreground">Noch kein Ziel.</p>
98
- : state.targets.map((target) => <TargetRow key={target.name} send={send} target={target} />)}
99
- <Card className="gap-4 p-4"><AddServer send={send} /></Card>
100
- <Card className="gap-4 p-4"><AddProfile picked={state.pickedProfileFile} send={send} /></Card>
82
+ <section className="grid gap-1.5">
83
+ <h2 className="flex items-baseline gap-2 text-[0.66rem] font-bold uppercase tracking-[0.06em] text-muted-foreground">
84
+ Umgebungen<span className="font-mono text-[0.62rem] font-normal tracking-normal opacity-80">{state.targets.length}</span>
85
+ </h2>
86
+ {state.targets.length === 0
87
+ ? <p className="text-[0.85rem] leading-normal text-muted-foreground">Noch keine Umgebung.</p>
88
+ : <ul className="grid">
89
+ {state.targets.map((target) => <TargetRow key={target.name} onEdit={(name) => setDialog({ kind: "edit", name })} onLogin={setLogin} send={send} target={target} />)}
90
+ </ul>}
91
+ </section>
92
+ <div className="flex flex-wrap items-center gap-2">
93
+ <Button onClick={() => setDialog({ kind: "new" })} size="sm"><PlusIcon data-icon="inline-start" />Neue Umgebung</Button>
94
+ </div>
95
+ {open && <TargetDialog key={editing?.name ?? "neu"} onClose={() => setDialog(undefined)} send={send} state={state} target={editing} />}
96
+ {loginTarget && <LoginDialog onClose={() => setLogin(undefined)} send={send} target={loginTarget} />}
101
97
  </div>;
102
98
  }