@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.
- package/README.md +10 -4
- package/apps/server/src/plugin-support/language-server/contract.ts +6 -1
- package/apps/server/src/plugin-support/language-server/tools.ts +29 -4
- package/apps/server/src/plugin-support/run-scripts.ts +4 -1
- package/apps/server/src/plugin-support/start-entries-contract.ts +4 -3
- package/apps/server/src/plugin-support/workspace-sandbox-host.ts +2 -0
- package/apps/server/src/profile-target.ts +25 -0
- package/apps/server/src/web-build.ts +82 -0
- package/apps/server/tsconfig.json +38 -0
- package/apps/web/column.html +12 -0
- package/apps/web/index.html +12 -0
- package/apps/web/src/App.tsx +0 -10
- package/apps/web/src/PluginChat.tsx +8 -8
- package/apps/web/src/PluginRegistry.tsx +0 -12
- package/apps/web/src/StartSurface.tsx +2 -2
- package/apps/web/src/ThemeSettings.tsx +1 -1
- package/apps/web/src/column/ColumnApp.tsx +83 -7
- package/apps/web/src/column/clipboard.ts +64 -0
- package/apps/web/src/column/host-contract.ts +10 -0
- package/apps/web/src/column.tsx +2 -0
- package/apps/web/src/panel/LoginForm.tsx +9 -3
- package/apps/web/src/panel/OverviewPage.tsx +187 -62
- package/apps/web/src/panel/PanelDialogs.tsx +115 -0
- package/apps/web/src/panel/PanelPage.tsx +1 -1
- package/apps/web/src/panel/SettingsPage.tsx +75 -79
- package/apps/web/src/panel/contract.ts +17 -9
- package/apps/web/src/panel/target-state.ts +27 -2
- package/apps/web/src/ui/theme.css +4 -0
- package/apps/web/tsconfig.json +41 -0
- package/apps/web/vite.config.ts +140 -0
- package/package.json +6 -2
- package/packages/ragents/src/plugin-host.ts +5 -2
- package/packages/ragents/src/plugin-types.ts +3 -2
- package/packages/workspace-executor/src/context.ts +4 -0
- package/packages/workspace-executor/src/executor.ts +9 -2
- package/packages/workspace-executor/src/index.ts +2 -0
- package/packages/workspace-executor/src/language-server/adapters/fsharp.ts +1 -0
- package/packages/workspace-executor/src/language-server/adapters/roslyn.ts +1 -0
- package/packages/workspace-executor/src/language-server/adapters/typescript.ts +34 -6
- package/packages/workspace-executor/src/language-server/contract.ts +7 -3
- package/packages/workspace-executor/src/language-server/host.ts +275 -138
- package/plugins/ragents.actor-programs/contract.ts +1 -1
- package/plugins/ragents.actor-programs/prompt.hbs +4 -4
- package/plugins/ragents.actor-programs/server/app-project.ts +0 -2
- package/plugins/ragents.actor-programs/server/runtime.ts +1 -1
- package/plugins/ragents.actor-programs/web/AppsPanel.tsx +3 -5
- package/plugins/ragents.actor-programs/web/EmbeddedApps.tsx +7 -39
- package/plugins/ragents.actor-programs/web/api.ts +1 -10
- package/plugins/ragents.lsp-roslyn/web/language-server-plugin.tsx +73 -44
- package/plugins/ragents.orchestration/canvas.hbs +28 -44
- package/plugins/ragents.orchestration/contract.ts +17 -287
- package/plugins/ragents.orchestration/server/canvas-tool.ts +18 -123
- package/plugins/ragents.orchestration/tiled-layout.ts +12 -4
- package/plugins/ragents.orchestration/web/ActorCanvasControls.tsx +23 -47
- package/plugins/ragents.orchestration/web/ActorChatPreview.tsx +3 -9
- package/plugins/ragents.orchestration/web/ActorShortcut.tsx +3 -5
- package/plugins/ragents.orchestration/web/ActorTile.tsx +3 -2
- package/plugins/ragents.orchestration/web/CanvasTiles.tsx +3 -3
- package/plugins/ragents.orchestration/web/Center.tsx +28 -463
- package/plugins/ragents.orchestration/web/MaterialBody.tsx +6 -87
- package/plugins/ragents.orchestration/web/TiledCanvas.tsx +2 -3
- package/plugins/ragents.orchestration/web/canvas-view-settings.ts +7 -13
- package/plugins/ragents.orchestration/web/column/RunColumn.tsx +44 -19
- package/plugins/ragents.orchestration/web/column/column-state.ts +13 -4
- package/plugins/ragents.orchestration/web/constants.ts +0 -2
- package/plugins/ragents.orchestration/web/index.tsx +0 -6
- package/plugins/ragents.orchestration/web/tile-docking.ts +7 -1
- package/plugins/ragents.orchestration/web/tiled-view-settings.ts +7 -9
- package/plugins/ragents.overseer/server/coordinator-prompt.ts +1 -1
- package/plugins/ragents.profile-distribution/server/web.ts +3 -27
- package/plugins/ragents.reference/examples.ts +1 -2
- package/plugins/ragents.reference/run-scripts/balcony-wizard/RUN.md +1 -1
- package/plugins/ragents.reference/run-scripts/balcony-wizard/actors/balcony-app/package.json +1 -1
- package/plugins/ragents.reference/run-scripts/balcony-wizard/src/server.ts +1 -1
- package/plugins/ragents.reference/run-scripts/balcony-wizard/tests/program.test.ts +2 -2
- package/plugins/ragents.reference/run-scripts/conversation-circle/RUN.md +3 -4
- package/plugins/ragents.reference/run-scripts/conversation-circle/src/server.ts +9 -15
- package/plugins/ragents.reference/run-scripts/conversation-circle/tests/program.test.ts +5 -3
- package/plugins/ragents.reference/run-scripts/learning-afternoon/package.json +1 -1
- package/plugins/ragents.reference/run-scripts/learning-afternoon/src/server.ts +14 -4
- package/plugins/ragents.reference/run-scripts/word-game/src/server.ts +1 -1
- package/plugins/ragents.reference/skills/210-moderator-handover/SKILL.md +1 -1
- package/plugins/ragents.reference/walkthroughs.ts +14 -14
- package/plugins/ragents.workspace/client/workspace-client.ts +4 -1
- package/scripts/agent/agent-cli.ts +9 -24
- package/scripts/package/build-package.ts +18 -5
- package/scripts/package/publish-package.ts +24 -10
- package/scripts/package/ragents.mjs +3 -2
- package/scripts/remote/connect.ts +16 -5
- package/scripts/remote/start.ts +33 -15
- package/scripts/workspace-client/run-workspace-client.ts +3 -1
- package/apps/web/src/MaterialSettings.tsx +0 -40
- package/apps/web/src/material-settings.ts +0 -104
- package/plugins/ragents.orchestration/web/ActorNet.tsx +0 -607
- package/plugins/ragents.orchestration/web/CardSizeSettings.tsx +0 -42
- package/plugins/ragents.orchestration/web/ScriptActorCard.tsx +0 -56
- package/plugins/ragents.orchestration/web/ZoomSettings.tsx +0 -38
- package/plugins/ragents.orchestration/web/canvas-layout-storage.ts +0 -39
- package/plugins/ragents.orchestration/web/canvas-layout.ts +0 -326
- package/plugins/ragents.orchestration/web/canvas-scene.ts +0 -311
- package/plugins/ragents.orchestration/web/card-size-settings.ts +0 -36
- package/plugins/ragents.orchestration/web/keyboard-navigation.ts +0 -55
- package/plugins/ragents.orchestration/web/material-layout.ts +0 -14
- package/plugins/ragents.orchestration/web/useCanvasMeasurements.ts +0 -78
- package/plugins/ragents.orchestration/web/zoom-settings.ts +0 -28
- package/plugins/ragents.reference/skills/120-canvas-conversation-circle/SKILL.md +0 -12
- package/plugins/ragents.reference/skills/130-canvas-pipeline-with-approval/SKILL.md +0 -12
- 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
|
|
66
|
-
|
|
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
|
-
|
|
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) {
|
package/apps/web/src/column.tsx
CHANGED
|
@@ -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
|
|
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
|
-
<
|
|
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 {
|
|
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 {
|
|
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
|
|
14
|
+
const NEW_RUN_GROUP = "Ohne Vorlage";
|
|
15
|
+
const NEW_RUN_TEXT = "Leerer Run, der Auftrag entsteht im Chat der Arbeitsspalte.";
|
|
13
16
|
|
|
14
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
176
|
+
/** Die Startseite des Panels: alle Umgebungen, darunter ihre Runs und ihre Startvorlagen als Kacheln. */
|
|
76
177
|
export function OverviewPage({ state, send }: PanelPageProps) {
|
|
77
|
-
|
|
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.
|
|
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
|
-
{
|
|
84
|
-
? <
|
|
85
|
-
<p className="text-[0.85rem] leading-normal text-muted-foreground">Noch
|
|
86
|
-
<Button onClick={() => send({ action: "page", page: "settings" })}><
|
|
87
|
-
</
|
|
88
|
-
:
|
|
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
|
}
|