@schlenkr/ragents 0.1.0 → 0.1.2

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 (108) hide show
  1. package/README.md +10 -4
  2. package/apps/server/src/plugin-support/language-server/contract.ts +6 -1
  3. package/apps/server/src/plugin-support/language-server/tools.ts +29 -4
  4. package/apps/server/src/plugin-support/run-scripts.ts +4 -1
  5. package/apps/server/src/plugin-support/start-entries-contract.ts +4 -3
  6. package/apps/server/src/plugin-support/workspace-sandbox-host.ts +2 -0
  7. package/apps/server/src/profile-target.ts +25 -0
  8. package/apps/server/src/web-build.ts +82 -0
  9. package/apps/server/tsconfig.json +38 -0
  10. package/apps/web/column.html +12 -0
  11. package/apps/web/index.html +12 -0
  12. package/apps/web/src/App.tsx +0 -10
  13. package/apps/web/src/PluginChat.tsx +8 -8
  14. package/apps/web/src/PluginRegistry.tsx +0 -12
  15. package/apps/web/src/StartSurface.tsx +2 -2
  16. package/apps/web/src/ThemeSettings.tsx +1 -1
  17. package/apps/web/src/column/ColumnApp.tsx +83 -7
  18. package/apps/web/src/column/clipboard.ts +64 -0
  19. package/apps/web/src/column/host-contract.ts +10 -0
  20. package/apps/web/src/column.tsx +2 -0
  21. package/apps/web/src/panel/LoginForm.tsx +9 -3
  22. package/apps/web/src/panel/OverviewPage.tsx +187 -62
  23. package/apps/web/src/panel/PanelDialogs.tsx +115 -0
  24. package/apps/web/src/panel/PanelPage.tsx +1 -1
  25. package/apps/web/src/panel/SettingsPage.tsx +75 -79
  26. package/apps/web/src/panel/contract.ts +17 -9
  27. package/apps/web/src/panel/target-state.ts +27 -2
  28. package/apps/web/src/ui/theme.css +4 -0
  29. package/apps/web/tsconfig.json +41 -0
  30. package/apps/web/vite.config.ts +140 -0
  31. package/package.json +6 -2
  32. package/packages/ragents/src/plugin-host.ts +5 -2
  33. package/packages/ragents/src/plugin-types.ts +3 -2
  34. package/packages/workspace-executor/src/context.ts +4 -0
  35. package/packages/workspace-executor/src/executor.ts +9 -2
  36. package/packages/workspace-executor/src/index.ts +2 -0
  37. package/packages/workspace-executor/src/language-server/adapters/fsharp.ts +1 -0
  38. package/packages/workspace-executor/src/language-server/adapters/roslyn.ts +1 -0
  39. package/packages/workspace-executor/src/language-server/adapters/typescript.ts +34 -6
  40. package/packages/workspace-executor/src/language-server/contract.ts +7 -3
  41. package/packages/workspace-executor/src/language-server/host.ts +275 -138
  42. package/plugins/ragents.actor-programs/contract.ts +1 -1
  43. package/plugins/ragents.actor-programs/prompt.hbs +4 -4
  44. package/plugins/ragents.actor-programs/server/app-project.ts +0 -2
  45. package/plugins/ragents.actor-programs/server/runtime.ts +1 -1
  46. package/plugins/ragents.actor-programs/web/AppsPanel.tsx +3 -5
  47. package/plugins/ragents.actor-programs/web/EmbeddedApps.tsx +7 -39
  48. package/plugins/ragents.actor-programs/web/api.ts +1 -10
  49. package/plugins/ragents.lsp-roslyn/web/language-server-plugin.tsx +73 -44
  50. package/plugins/ragents.orchestration/canvas.hbs +28 -44
  51. package/plugins/ragents.orchestration/contract.ts +17 -287
  52. package/plugins/ragents.orchestration/server/canvas-tool.ts +18 -123
  53. package/plugins/ragents.orchestration/tiled-layout.ts +12 -4
  54. package/plugins/ragents.orchestration/web/ActorCanvasControls.tsx +23 -47
  55. package/plugins/ragents.orchestration/web/ActorChatPreview.tsx +3 -9
  56. package/plugins/ragents.orchestration/web/ActorShortcut.tsx +3 -5
  57. package/plugins/ragents.orchestration/web/ActorTile.tsx +3 -2
  58. package/plugins/ragents.orchestration/web/CanvasTiles.tsx +3 -3
  59. package/plugins/ragents.orchestration/web/Center.tsx +28 -463
  60. package/plugins/ragents.orchestration/web/MaterialBody.tsx +6 -87
  61. package/plugins/ragents.orchestration/web/TiledCanvas.tsx +2 -3
  62. package/plugins/ragents.orchestration/web/canvas-view-settings.ts +7 -13
  63. package/plugins/ragents.orchestration/web/column/RunColumn.tsx +44 -19
  64. package/plugins/ragents.orchestration/web/column/column-state.ts +13 -4
  65. package/plugins/ragents.orchestration/web/constants.ts +0 -2
  66. package/plugins/ragents.orchestration/web/index.tsx +0 -6
  67. package/plugins/ragents.orchestration/web/tile-docking.ts +7 -1
  68. package/plugins/ragents.orchestration/web/tiled-view-settings.ts +7 -9
  69. package/plugins/ragents.overseer/server/coordinator-prompt.ts +1 -1
  70. package/plugins/ragents.profile-distribution/server/web.ts +3 -27
  71. package/plugins/ragents.reference/examples.ts +1 -2
  72. package/plugins/ragents.reference/run-scripts/balcony-wizard/RUN.md +1 -1
  73. package/plugins/ragents.reference/run-scripts/balcony-wizard/actors/balcony-app/package.json +1 -1
  74. package/plugins/ragents.reference/run-scripts/balcony-wizard/src/server.ts +1 -1
  75. package/plugins/ragents.reference/run-scripts/balcony-wizard/tests/program.test.ts +2 -2
  76. package/plugins/ragents.reference/run-scripts/conversation-circle/RUN.md +3 -4
  77. package/plugins/ragents.reference/run-scripts/conversation-circle/src/server.ts +9 -15
  78. package/plugins/ragents.reference/run-scripts/conversation-circle/tests/program.test.ts +5 -3
  79. package/plugins/ragents.reference/run-scripts/learning-afternoon/package.json +1 -1
  80. package/plugins/ragents.reference/run-scripts/learning-afternoon/src/server.ts +14 -4
  81. package/plugins/ragents.reference/run-scripts/word-game/src/server.ts +1 -1
  82. package/plugins/ragents.reference/skills/210-moderator-handover/SKILL.md +1 -1
  83. package/plugins/ragents.reference/walkthroughs.ts +14 -14
  84. package/plugins/ragents.workspace/client/workspace-client.ts +4 -1
  85. package/scripts/agent/agent-cli.ts +9 -24
  86. package/scripts/package/build-package.ts +18 -5
  87. package/scripts/package/publish-package.ts +24 -10
  88. package/scripts/package/ragents.mjs +3 -2
  89. package/scripts/remote/connect.ts +16 -5
  90. package/scripts/remote/start.ts +33 -15
  91. package/scripts/workspace-client/run-workspace-client.ts +3 -1
  92. package/apps/web/src/MaterialSettings.tsx +0 -40
  93. package/apps/web/src/material-settings.ts +0 -104
  94. package/plugins/ragents.orchestration/web/ActorNet.tsx +0 -607
  95. package/plugins/ragents.orchestration/web/CardSizeSettings.tsx +0 -42
  96. package/plugins/ragents.orchestration/web/ScriptActorCard.tsx +0 -56
  97. package/plugins/ragents.orchestration/web/ZoomSettings.tsx +0 -38
  98. package/plugins/ragents.orchestration/web/canvas-layout-storage.ts +0 -39
  99. package/plugins/ragents.orchestration/web/canvas-layout.ts +0 -326
  100. package/plugins/ragents.orchestration/web/canvas-scene.ts +0 -311
  101. package/plugins/ragents.orchestration/web/card-size-settings.ts +0 -36
  102. package/plugins/ragents.orchestration/web/keyboard-navigation.ts +0 -55
  103. package/plugins/ragents.orchestration/web/material-layout.ts +0 -14
  104. package/plugins/ragents.orchestration/web/useCanvasMeasurements.ts +0 -78
  105. package/plugins/ragents.orchestration/web/zoom-settings.ts +0 -28
  106. package/plugins/ragents.reference/skills/120-canvas-conversation-circle/SKILL.md +0 -12
  107. package/plugins/ragents.reference/skills/130-canvas-pipeline-with-approval/SKILL.md +0 -12
  108. package/plugins/ragents.reference/skills/140-canvas-team-tree-and-topic-grid/SKILL.md +0 -12
@@ -1,6 +1,10 @@
1
- import { useCallback, useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { ChevronDownIcon, ChevronUpIcon, LayoutGridIcon } from "lucide-react";
3
- import { canStartEntry } from "../../../../packages/ragents/src/access";
3
+ import { canStartEntry, type AccessContext } from "../../../../packages/ragents/src/access";
4
+ import { getStartOptions, setStartOption } from "../api";
5
+ import { sendChatMessage, startChatEntry } from "../chat/requests";
6
+ import { preparedRunInput } from "../run-preparation";
7
+ import { initialStartOptionUpdates } from "../StartOptions";
4
8
  import { AccessScreen, useAccess } from "../AccessContext";
5
9
  import type { SessionInfo } from "../api";
6
10
  import { PluginChat } from "../PluginChat";
@@ -9,7 +13,7 @@ import { usePluginActivation } from "../PluginActivation";
9
13
  import { useRunReadState } from "../run-read-state";
10
14
  import { SessionList } from "../SessionList";
11
15
  import { SettingsModal } from "../SettingsModal";
12
- import { Alert, Button } from "../ui";
16
+ import { Alert, Button, Spinner } from "../ui";
13
17
  import type { ColumnLocation } from "./column-location";
14
18
  import { ColumnMenu } from "./ColumnMenu";
15
19
  import { useColumnHost } from "./host";
@@ -45,25 +49,40 @@ function ColumnPage({ initialRunId, registry }: { initialRunId: string | undefin
45
49
  const canCreate = writeRuns && (access.can("runs.create") || registry.scriptEntries.some((entry) => canStartEntry(access, entry.id)));
46
50
  const runReadState = useRunReadState(access.user?.id);
47
51
  const [runId, setRunId] = useState(initialRunId);
52
+ const [runStartOptions, setRunStartOptions] = useState<Readonly<Record<string, unknown>>>();
48
53
  const [draft, setDraft] = useState<{ session: SessionInfo; startOptions?: Readonly<Record<string, unknown>>; entryId?: string }>();
54
+ const [launch, setLaunch] = useState<Launch>();
49
55
  const [settingsOpen, setSettingsOpen] = useState(false);
50
56
  const [listOpen, setListOpen] = useState(false);
51
57
  const openSettings = useCallback(() => setSettingsOpen(true), []);
52
- const { sessions, unreachable, refresh } = useSessionList(readRuns && !draft);
58
+ const { sessions, unreachable, refresh } = useSessionList(readRuns && !draft && !launch);
53
59
  const [headerContainer, setHeaderContainer] = useState<HTMLDivElement | null>(null);
54
60
  const [toolsContainer, setToolsContainer] = useState<HTMLDivElement | null>(null);
55
61
  const startDraft = useCallback((startOptions?: Readonly<Record<string, unknown>>, entryId?: string) => { setListOpen(false); setDraft({ session: newDraft(), startOptions, entryId }); }, []);
56
62
  const createRun = useCallback(() => startDraft(), [startDraft]);
63
+ const openRun = useCallback((id: string, startOptions?: Readonly<Record<string, unknown>>) => {
64
+ setListOpen(false);
65
+ setDraft(undefined);
66
+ setLaunch(undefined);
67
+ setRunStartOptions(startOptions);
68
+ setRunId(id);
69
+ }, []);
57
70
  const selectRun = useCallback((id: string) => { setListOpen(false); setRunId(id); }, []);
58
71
 
59
72
  useEffect(() => host.onCommand((message) => {
60
73
  if (message.type === "selectRun") {
61
74
  setDraft(undefined);
75
+ setLaunch(undefined);
62
76
  setListOpen(false);
77
+ setRunStartOptions(undefined);
63
78
  setRunId(message.runId ?? undefined);
64
79
  }
65
- if (message.type === "newRun" && canCreate) startDraft(message.startOptions, message.entryId);
66
- }), [canCreate, host, startDraft]);
80
+ if (message.type !== "newRun" || !canCreate) return;
81
+ // Im Panel ist ein Klick ein Klick: die Vorlage startet sofort, der freie Auftrag entsteht im Chat des neuen Runs.
82
+ if (host.kind !== "vscode") startDraft(message.startOptions, message.entryId);
83
+ else if (message.entryId === undefined) openRun(crypto.randomUUID(), message.startOptions);
84
+ else setLaunch({ runId: crypto.randomUUID(), entryId: message.entryId, startOptions: message.startOptions });
85
+ }), [canCreate, host, openRun, startDraft]);
67
86
  useEffect(() => { host.notify({ type: "ready" }); }, [host]);
68
87
  useEffect(() => { host.notify({ type: "runChanged", runId: runId ?? null }); }, [host, runId]);
69
88
  useEffect(() => {
@@ -73,6 +92,9 @@ function ColumnPage({ initialRunId, registry }: { initialRunId: string | undefin
73
92
  return () => window.removeEventListener("keydown", onKeyDown);
74
93
  }, [listOpen]);
75
94
 
95
+ if (launch) return <RunLaunch access={access} launch={launch} onCancel={() => setLaunch(undefined)} onOpenSettings={openSettings}
96
+ onStarted={openRun} registry={registry} />;
97
+
76
98
  const session = sessions.find((entry) => entry.id === runId);
77
99
  const list = readRuns
78
100
  ? <SessionList activeId={runId} onCreate={canCreate ? createRun : undefined} onSelect={selectRun} onToggleSelected={() => undefined} registry={registry} seenRevisions={runReadState.revisions} selectMode={false} selectedIds={noSelection} sessions={sessions} />
@@ -90,8 +112,9 @@ function ColumnPage({ initialRunId, registry }: { initialRunId: string | undefin
90
112
  </header>
91
113
  <main className="relative z-1 flex min-h-0 min-w-0 flex-1 @container/chat-content">
92
114
  <PluginChat
93
- canvasToolbarContainer={toolsContainer}
115
+ toolbarContainer={toolsContainer}
94
116
  headerContainer={headerContainer}
117
+ initialStartOptions={runStartOptions}
95
118
  key={runId}
96
119
  layout="column"
97
120
  onViewed={runReadState.markViewed}
@@ -134,6 +157,59 @@ function ColumnPage({ initialRunId, registry }: { initialRunId: string | undefin
134
157
  );
135
158
  }
136
159
 
160
+ interface Launch {
161
+ readonly runId: string;
162
+ readonly entryId: string;
163
+ readonly startOptions?: Readonly<Record<string, unknown>>;
164
+ }
165
+
166
+ /** Ein Klick auf eine Kachel der Übersicht: Startoptionen setzen, den Einstieg starten, fertig. */
167
+ async function launchRun(launch: Launch, registry: PluginRegistry, access: AccessContext): Promise<void> {
168
+ const entry = registry.startEntries.find((candidate) => candidate.id === launch.entryId);
169
+ if (!entry) throw new Error("Diese Startvorlage gibt es in diesem Profil nicht.");
170
+ if (!canStartEntry(access, entry.id)) throw new Error(`${entry.title} ist für dieses Benutzerkonto nicht freigegeben.`);
171
+ if (launch.startOptions) {
172
+ const options = await getStartOptions(launch.runId);
173
+ for (const [id, value] of initialStartOptionUpdates(options, launch.startOptions)) await setStartOption(launch.runId, id, value);
174
+ }
175
+ if (entry.action === "script") await startChatEntry(launch.runId, entry.id, null);
176
+ else await sendChatMessage(launch.runId, preparedRunInput([], entry.prompt, undefined, entry.skill).text);
177
+ }
178
+
179
+ function RunLaunch({ launch, registry, access, onStarted, onCancel, onOpenSettings }: {
180
+ launch: Launch;
181
+ registry: PluginRegistry;
182
+ access: AccessContext;
183
+ onStarted: (runId: string) => void;
184
+ onCancel: () => void;
185
+ onOpenSettings: () => void;
186
+ }) {
187
+ const [error, setError] = useState<string>();
188
+ const running = useRef(false);
189
+ useEffect(() => {
190
+ if (running.current) return;
191
+ running.current = true;
192
+ void launchRun(launch, registry, access).then(
193
+ () => onStarted(launch.runId),
194
+ (cause: unknown) => setError(cause instanceof Error ? cause.message : String(cause)),
195
+ );
196
+ }, [access, launch, onStarted, registry]);
197
+ return (
198
+ <div className="flex h-full flex-col bg-[image:var(--canvas-backdrop)]">
199
+ <header className={listHeaderClass}>
200
+ <LayoutGridIcon aria-hidden className="size-4 flex-none text-muted-foreground" />
201
+ <strong className="min-w-0 flex-1 truncate text-[0.82rem] font-semibold">{registry.brand.title}</strong>
202
+ <ColumnMenu onOpenSettings={onOpenSettings} />
203
+ </header>
204
+ <div className="flex min-h-0 flex-1 items-center justify-center p-6">
205
+ {error
206
+ ? <Alert className="max-w-[420px] gap-3" variant="destructive"><p>{error}</p><Button onClick={onCancel} variant="outline">Zurück</Button></Alert>
207
+ : <p className="flex items-center gap-2 text-muted-foreground" role="status"><Spinner />Run wird gestartet ...</p>}
208
+ </div>
209
+ </div>
210
+ );
211
+ }
212
+
137
213
  function ElementPage({ elementId, registry, runId }: { elementId: string; registry: PluginRegistry; runId: string }) {
138
214
  const host = useColumnHost();
139
215
  const readRuns = useAccess().can("runs.read");
@@ -0,0 +1,64 @@
1
+ import { isClipboardColumnMessage } from "./host-contract";
2
+
3
+ type EditingAction = "paste" | "copy" | "cut";
4
+
5
+ const actionOf = (event: KeyboardEvent): EditingAction | undefined => {
6
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return undefined;
7
+ switch (event.key) {
8
+ case "v": return "paste";
9
+ case "c": return "copy";
10
+ case "x": return "cut";
11
+ default: return undefined;
12
+ }
13
+ };
14
+
15
+ const isEditable = (element: Element | null): element is HTMLElement =>
16
+ element !== null && (element.tagName === "INPUT" || element.tagName === "TEXTAREA" || (element as HTMLElement).isContentEditable === true);
17
+
18
+ /**
19
+ * macOS liefert Einfügen, Kopieren und Ausschneiden über das Menü der Anwendung, und VS Code reicht
20
+ * den Befehl nur an das Dokument seines Webviews weiter, nicht in ein iframe fremder Herkunft. Die
21
+ * Spalte führt ihn deshalb selbst aus; den Text der Zwischenablage holt sie über die Hülle.
22
+ */
23
+ export function installClipboardBridge(browser: Window): () => void {
24
+ const parent = browser.parent;
25
+ const pending = new Map<string, (text: string) => void>();
26
+
27
+ const onMessage = (event: MessageEvent) => {
28
+ if (event.source !== parent || !isClipboardColumnMessage(event.data)) return;
29
+ const settle = pending.get(event.data.id);
30
+ if (!settle) return;
31
+ pending.delete(event.data.id);
32
+ settle(event.data.text);
33
+ };
34
+
35
+ const read = () => new Promise<string>((resolve) => {
36
+ const id = crypto.randomUUID();
37
+ pending.set(id, resolve);
38
+ parent.postMessage({ type: "clipboardRead", id }, "*");
39
+ });
40
+
41
+ const onKeyDown = (event: KeyboardEvent) => {
42
+ const action = actionOf(event);
43
+ if (action === undefined || event.defaultPrevented) return;
44
+ const target = browser.document.activeElement;
45
+ if (action !== "copy" && !isEditable(target)) return;
46
+ event.preventDefault();
47
+ if (action !== "paste") {
48
+ browser.document.execCommand(action);
49
+ return;
50
+ }
51
+ void read().then((text) => {
52
+ if (!text || !isEditable(target)) return;
53
+ target.focus();
54
+ browser.document.execCommand("insertText", false, text);
55
+ });
56
+ };
57
+
58
+ browser.addEventListener("message", onMessage);
59
+ browser.addEventListener("keydown", onKeyDown, true);
60
+ return () => {
61
+ browser.removeEventListener("message", onMessage);
62
+ browser.removeEventListener("keydown", onKeyDown, true);
63
+ };
64
+ }
@@ -31,6 +31,10 @@ export type ColumnHostMessage =
31
31
  | { type: "openExternal"; url: string }
32
32
  | { type: "openPage"; url: string; title: string };
33
33
 
34
+ /** Der Text der Zwischenablage: ein iframe fremder Herkunft darf sie im Webview nicht lesen, die Hülle darüber schon. */
35
+ export type ColumnClipboardMessage = { type: "clipboardRead"; id: string };
36
+ export type ClipboardColumnMessage = { type: "clipboardText"; id: string; text: string };
37
+
34
38
  /** Nachrichten des Hosts an das Panel. */
35
39
  export type HostColumnMessage =
36
40
  | { type: "selectRun"; runId: string | null }
@@ -65,6 +69,12 @@ export const isColumnHostMessage = (value: unknown): value is ColumnHostMessage
65
69
  }
66
70
  };
67
71
 
72
+ export const isColumnClipboardMessage = (value: unknown): value is ColumnClipboardMessage =>
73
+ isObject(value) && value.type === "clipboardRead" && isText(value.id);
74
+
75
+ export const isClipboardColumnMessage = (value: unknown): value is ClipboardColumnMessage =>
76
+ isObject(value) && value.type === "clipboardText" && isText(value.id) && typeof value.text === "string";
77
+
68
78
  export const isHostColumnMessage = (value: unknown): value is HostColumnMessage => {
69
79
  if (!isObject(value)) return false;
70
80
  switch (value.type) {
@@ -7,6 +7,7 @@ import { createRoot } from "react-dom/client";
7
7
  import { Button } from "./ui";
8
8
  import { AccessGate, AccessScreen } from "./AccessContext";
9
9
  import { installAccessToken } from "./access-token";
10
+ import { installClipboardBridge } from "./column/clipboard";
10
11
  import { ColumnApp, HostLogin } from "./column/ColumnApp";
11
12
  import { parseColumnLocation } from "./column/column-location";
12
13
  import { ColumnHostProvider, createColumnHost } from "./column/host";
@@ -21,6 +22,7 @@ try {
21
22
  const location = parseColumnLocation(window.location.search);
22
23
  if (location.access !== undefined) installAccessToken(location.access);
23
24
  const host = createColumnHost(location.host, window);
25
+ if (location.host === "vscode") installClipboardBridge(window);
24
26
  const theme = initializeTheme(window);
25
27
  if (location.theme !== undefined) theme.setPreference(location.theme);
26
28
  host.onCommand((message) => { if (message.type === "theme") theme.setPreference(message.theme); });
@@ -2,13 +2,14 @@ import { useState, type FormEvent } from "react";
2
2
  import { Button, Input, Label } from "../ui";
3
3
  import type { PanelAction, TargetView } from "./contract";
4
4
 
5
- /** Die Anmeldung eines Ziels, direkt in seiner Karte: Benutzer und Passwort oder ein Zugangstoken. */
6
- export function LoginForm({ target, mode, busy, send, submitLabel }: {
5
+ /** Die Anmeldung einer Umgebung im Dialogkörper: Benutzer und Passwort oder ein Zugangstoken. */
6
+ export function LoginForm({ target, mode, busy, send, submitLabel, onCancel }: {
7
7
  target: TargetView;
8
8
  mode: "password" | "token";
9
9
  busy: boolean;
10
10
  send: (action: PanelAction) => void;
11
11
  submitLabel: string;
12
+ onCancel?: () => void;
12
13
  }) {
13
14
  const [user, setUser] = useState(target.loginUser ?? "");
14
15
  const [password, setPassword] = useState("");
@@ -26,6 +27,11 @@ export function LoginForm({ target, mode, busy, send, submitLabel }: {
26
27
  <div className="grid gap-1.5"><Label htmlFor={`user-${field}`}>Benutzer</Label><Input autoComplete="username" disabled={busy} id={`user-${field}`} onChange={(event) => setUser(event.target.value)} value={user} /></div>
27
28
  <div className="grid gap-1.5"><Label htmlFor={`password-${field}`}>Passwort</Label><Input autoComplete="current-password" disabled={busy} id={`password-${field}`} onChange={(event) => setPassword(event.target.value)} type="password" value={password} /></div>
28
29
  </>}
29
- <Button className="w-full" disabled={busy || !complete} size="sm" type="submit">{submitLabel}</Button>
30
+ {target.problem && <p className="text-[0.75rem] leading-normal text-destructive [overflow-wrap:anywhere]" role="alert">{target.problem}</p>}
31
+ <p className="text-[0.72rem] leading-normal text-muted-foreground">Die Anmeldedaten liegen im Schlüsselspeicher von VS Code; die Umgebung meldet sich danach still an.</p>
32
+ <div className="mt-1 flex flex-wrap justify-end gap-2">
33
+ {onCancel && <Button onClick={onCancel} size="sm" type="button" variant="ghost">Abbrechen</Button>}
34
+ <Button disabled={busy || !complete} size="sm" type="submit">{submitLabel}</Button>
35
+ </div>
30
36
  </form>;
31
37
  }
@@ -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
  }