agent-canvas 0.1.40 → 0.1.42
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 +18 -1
- package/bin/agent-canvas.ts +5 -1
- package/bin/lib/commands/exec.test.ts +27 -0
- package/bin/lib/commands/exec.ts +129 -0
- package/bin/lib/commands/watch.ts +8 -1
- package/bin/lib/config.ts +1 -0
- package/bin/lib/daemon-lifecycle.ts +3 -1
- package/bin/lib/helpers.test.ts +48 -0
- package/bin/lib/helpers.ts +65 -15
- package/bin/lib/instructions/component-secretinput.md +61 -0
- package/bin/lib/instructions/flow-feature.md +1 -1
- package/bin/lib/instructions/flow-plan.md +1 -1
- package/bin/lib/instructions/flow-runbook.md +77 -0
- package/bin/lib/instructions/overview.md +32 -12
- package/daemon/client/AnnotationProvider.tsx +2 -7
- package/daemon/client/App.tsx +12 -2
- package/daemon/client/annotationDraft.test.ts +16 -1
- package/daemon/client/annotationDraft.ts +15 -0
- package/daemon/client/clientApi.ts +41 -0
- package/daemon/client/components/SecretInput.tsx +156 -0
- package/daemon/client/components/index.ts +1 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +28 -28
- package/daemon/dist/components.js +42 -40
- package/daemon/src/compiler.test.ts +5 -0
- package/daemon/src/compiler.ts +2 -2
- package/daemon/src/handlers/api.ts +81 -0
- package/daemon/src/handlers/utils.ts +1 -1
- package/daemon/src/paths.ts +3 -0
- package/daemon/src/secrets-api.test.ts +123 -0
- package/daemon/src/server.ts +9 -5
- package/daemon/src/session-secrets.test.ts +83 -0
- package/daemon/src/session.ts +55 -11
- package/package.json +1 -1
- package/skills/canvas/SKILL.md +18 -7
- package/workers/canvas-share/public/assets/client.css +1 -1
- package/workers/canvas-share/public/assets/client.js +28 -28
- package/workers/canvas-share/public/assets/components.js +42 -40
|
@@ -4,16 +4,17 @@ Canvas opens a rich, annotatable document in the user's browser. You write JSX,
|
|
|
4
4
|
|
|
5
5
|
Use it whenever structured visual communication beats inline chat: plans, reviews, decisions, explanations, discovery interviews.
|
|
6
6
|
|
|
7
|
-
**Note**: Replace all `<session-id>` references below with
|
|
7
|
+
**Note**: Replace all `<session-id>` references below with the stable canvas
|
|
8
|
+
session ID resolved by the skill.
|
|
8
9
|
|
|
9
10
|
## Core Workflow
|
|
10
11
|
|
|
11
12
|
### 1. Write JSX
|
|
12
13
|
|
|
13
|
-
Use the **Write** tool to create `.jsx` files in `$TMPDIR/agent-canvas/<session-id>/`:
|
|
14
|
+
Use the **Write** tool to create `.jsx` files in `${TMPDIR:-/tmp}/agent-canvas/<session-id>/`:
|
|
14
15
|
|
|
15
16
|
```jsx
|
|
16
|
-
// $TMPDIR/agent-canvas/<session-id>/plan.jsx
|
|
17
|
+
// ${TMPDIR:-/tmp}/agent-canvas/<session-id>/plan.jsx
|
|
17
18
|
|
|
18
19
|
<Section title="Authentication Redesign">
|
|
19
20
|
A proposal to replace session-based auth with JWT tokens.
|
|
@@ -41,7 +42,7 @@ Components are auto-available — no imports needed. The file can be a JSX fragm
|
|
|
41
42
|
Push the directory to open all canvases in the browser:
|
|
42
43
|
|
|
43
44
|
```bash
|
|
44
|
-
bunx agent-canvas push $TMPDIR/agent-canvas/<session-id>/ --session <session-id> --label "Implementation Plan"
|
|
45
|
+
bunx agent-canvas push ${TMPDIR:-/tmp}/agent-canvas/<session-id>/ --session <session-id> --label "Implementation Plan"
|
|
45
46
|
```
|
|
46
47
|
|
|
47
48
|
All `*.jsx` files in the directory are pushed as a snapshot. Each file appears as a separate tab. **Always show the `browserUrl` from the output to the user.**
|
|
@@ -52,16 +53,32 @@ Options:
|
|
|
52
53
|
|
|
53
54
|
### 3. Watch for feedback
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
Start watching immediately after every push:
|
|
56
57
|
|
|
57
58
|
```bash
|
|
58
|
-
# Use Bash tool with run_in_background: true
|
|
59
59
|
bunx agent-canvas watch --session <session-id>
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
How to run the watcher depends on the host:
|
|
63
63
|
|
|
64
|
-
**
|
|
64
|
+
- **Codex**: Keep the watcher attached to the current agent turn. Run it without
|
|
65
|
+
detaching or backgrounding it. If the shell tool yields a process/session
|
|
66
|
+
handle, call `write_stdin` on that same handle until the command returns the
|
|
67
|
+
feedback. Do not end the agent turn while the watcher is still running.
|
|
68
|
+
- **Claude Code**: Run the watcher in the background using the Bash tool's
|
|
69
|
+
`run_in_background` parameter. You will be **automatically notified** when the
|
|
70
|
+
user submits feedback. Do NOT poll, sleep, or proactively check. Just stop and
|
|
71
|
+
wait.
|
|
72
|
+
- **Other hosts**: Keep the watcher in the foreground unless the host explicitly
|
|
73
|
+
guarantees that background-process completion wakes the agent.
|
|
74
|
+
|
|
75
|
+
In Codex, a wait may yield before feedback arrives. Continue waiting on the same
|
|
76
|
+
process handle; do not start another watcher. Keep user-facing progress updates
|
|
77
|
+
brief and no more than 60 seconds apart while waiting.
|
|
78
|
+
|
|
79
|
+
**Important**: The push → watch sequence is atomic. Never push without starting
|
|
80
|
+
the host-specific watcher immediately afterward. Do not continue with unrelated
|
|
81
|
+
work while waiting for feedback.
|
|
65
82
|
|
|
66
83
|
### 4. Iterate
|
|
67
84
|
|
|
@@ -82,7 +99,7 @@ Returns immediately — prints feedback if available, otherwise no output.
|
|
|
82
99
|
Use different files for different phases or concerns:
|
|
83
100
|
|
|
84
101
|
```
|
|
85
|
-
$TMPDIR/agent-canvas/<session-id>/
|
|
102
|
+
${TMPDIR:-/tmp}/agent-canvas/<session-id>/
|
|
86
103
|
discovery.jsx # Discovery interview
|
|
87
104
|
requirements.jsx # Requirements spec
|
|
88
105
|
plan.jsx # Implementation plan
|
|
@@ -92,7 +109,7 @@ Write new files as phases progress, then push the directory. Previous files rema
|
|
|
92
109
|
|
|
93
110
|
## File Location
|
|
94
111
|
|
|
95
|
-
All canvas files go in `$TMPDIR/agent-canvas/<session-id>/`. This is a system temp directory — no `.gitignore` needed.
|
|
112
|
+
All canvas files go in `${TMPDIR:-/tmp}/agent-canvas/<session-id>/`. This is a system temp directory — no `.gitignore` needed.
|
|
96
113
|
|
|
97
114
|
## Components
|
|
98
115
|
|
|
@@ -121,6 +138,7 @@ All components are globally available in canvas JSX — no imports needed. For i
|
|
|
121
138
|
- **Choice** — single-select radio group. Props: `id`, `label`, `options`, `required`
|
|
122
139
|
- **MultiChoice** — multi-select checkboxes. Props: `id`, `label`, `options`, `required`
|
|
123
140
|
- **UserInput** — free text input. Props: `id`, `label`, `placeholder`, `required`
|
|
141
|
+
- **SecretInput** — local in-memory secret handoff. Props: `id`, `label`, `env`, `placeholder`, `required`
|
|
124
142
|
- **RangeInput** — numeric slider. Props: `id`, `label`, `min`, `max`, `minLabel`, `maxLabel`, `required`
|
|
125
143
|
|
|
126
144
|
### Advanced
|
|
@@ -146,6 +164,7 @@ User wants something built/changed?
|
|
|
146
164
|
User wants to understand something? → EXPLAIN flow
|
|
147
165
|
User wants review/audit? → REVIEW flow
|
|
148
166
|
User wants to make a decision? → DECISION flow
|
|
167
|
+
Agent needs the user to complete manual steps? → RUNBOOK flow
|
|
149
168
|
```
|
|
150
169
|
|
|
151
170
|
**Adapt dynamically**: flows are guidelines, not rigid pipelines. Skip or combine phases when appropriate:
|
|
@@ -167,17 +186,18 @@ User wants to make a decision? → DECISION flow
|
|
|
167
186
|
2. **Announce** briefly: "I'll start with discovery, then create a detailed plan."
|
|
168
187
|
3. **Write canvas JSX** with the Write tool
|
|
169
188
|
4. **Push + show browserUrl** to the user
|
|
170
|
-
5. **IMMEDIATELY watch
|
|
189
|
+
5. **IMMEDIATELY watch using the host-specific strategy above** — the push → watch sequence is atomic
|
|
171
190
|
6. **Read feedback** — check for annotations, answers, context files
|
|
172
191
|
7. **Edit and re-push + watch**, or advance to next phase
|
|
173
192
|
8. **After implementation**, push a summary canvas and **watch for feedback** — the user may want to respond
|
|
174
193
|
|
|
175
194
|
## Important Rules
|
|
176
195
|
|
|
177
|
-
- **Never `cd`** before running `bunx agent-canvas` commands. The daemon uses the current working directory to resolve project file paths (`<FilePreview>`, file browser). Always run commands from the project root. Since canvas files are in `$TMPDIR`, use absolute paths to reference them — e.g. `bunx agent-canvas push $TMPDIR/agent-canvas/<session-id>/`.
|
|
196
|
+
- **Never `cd`** before running `bunx agent-canvas` commands. The daemon uses the current working directory to resolve project file paths (`<FilePreview>`, file browser). Always run commands from the project root. Since canvas files are in `${TMPDIR:-/tmp}`, use absolute paths to reference them — e.g. `bunx agent-canvas push ${TMPDIR:-/tmp}/agent-canvas/<session-id>/`.
|
|
178
197
|
- **Write** canvas files using the Write tool. Never use bash heredocs.
|
|
179
198
|
- **Edit** canvas files using the Edit tool. Never rewrite entire files.
|
|
180
199
|
- Every `<Item>` and interactive component needs a unique `id`.
|
|
200
|
+
- Use `<SecretInput>`, never `<UserInput>`, for credentials. Read `component-secretinput` and `flow-runbook` first.
|
|
181
201
|
- `<FilePreview path="...">` paths are relative to project root.
|
|
182
202
|
- Read any files the user added to context (listed under "Added context" in feedback).
|
|
183
203
|
- Always push a summary canvas after implementation, even if brief.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
2
2
|
import { AnnotationCtx } from "#canvas/runtime";
|
|
3
3
|
import type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue } from "#canvas/runtime";
|
|
4
|
+
import { clearPersistedDraft } from "./annotationDraft";
|
|
4
5
|
import { generateAnnotationId } from "./utils";
|
|
5
6
|
|
|
6
7
|
// Re-export types for convenience
|
|
@@ -44,12 +45,6 @@ function savePersisted(sessionId: string, revision: number, state: PersistedStat
|
|
|
44
45
|
} catch {}
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function clearPersisted(sessionId: string, revision: number) {
|
|
48
|
-
try {
|
|
49
|
-
localStorage.removeItem(storageKey(sessionId, revision));
|
|
50
|
-
} catch {}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
48
|
export function AnnotationProvider({ sessionId, revision, isReadOnly, remoteAnnotations, children }: AnnotationProviderProps) {
|
|
54
49
|
const [localAnnotations, setAnnotations] = useState<Annotation[]>(() => {
|
|
55
50
|
const saved = loadPersisted(sessionId, revision);
|
|
@@ -164,7 +159,7 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, remoteAnno
|
|
|
164
159
|
setActiveAnnotationId(null);
|
|
165
160
|
setResponses(new Map());
|
|
166
161
|
setFeedbackEntries(new Map());
|
|
167
|
-
|
|
162
|
+
clearPersistedDraft(localStorage, sessionId, revision);
|
|
168
163
|
}, [sessionId, revision]);
|
|
169
164
|
|
|
170
165
|
return (
|
package/daemon/client/App.tsx
CHANGED
|
@@ -21,7 +21,7 @@ import { ShareDialog, ShareButton, type ShareEntry } from "./ShareDialog";
|
|
|
21
21
|
import { ReviewerIdentityDialog } from "./ReviewerIdentityDialog";
|
|
22
22
|
import type { Annotation } from "#canvas/runtime";
|
|
23
23
|
import { MODE, fetchMeta, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity } from "./clientApi";
|
|
24
|
-
import { carryUnsubmittedDraft } from "./annotationDraft";
|
|
24
|
+
import { carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
|
|
25
25
|
|
|
26
26
|
export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
|
|
27
27
|
|
|
@@ -314,6 +314,9 @@ function App() {
|
|
|
314
314
|
.then((data: any) => {
|
|
315
315
|
if (data.currentRevision) {
|
|
316
316
|
const nextRevisions: RevisionInfo[] = data.revisions || [];
|
|
317
|
+
for (const revision of nextRevisions) {
|
|
318
|
+
if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision);
|
|
319
|
+
}
|
|
317
320
|
carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, nextRevisions);
|
|
318
321
|
setCurrentRevision(data.currentRevision);
|
|
319
322
|
setSelectedRevision(data.currentRevision);
|
|
@@ -407,7 +410,13 @@ function App() {
|
|
|
407
410
|
}
|
|
408
411
|
}
|
|
409
412
|
if (data.type === "revision-updated") {
|
|
410
|
-
if (
|
|
413
|
+
if (Array.isArray(data.revisions)) {
|
|
414
|
+
const nextRevisions: RevisionInfo[] = data.revisions;
|
|
415
|
+
for (const revision of nextRevisions) {
|
|
416
|
+
if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision);
|
|
417
|
+
}
|
|
418
|
+
setRevisions(nextRevisions);
|
|
419
|
+
}
|
|
411
420
|
}
|
|
412
421
|
if (data.type === "watcher-status") {
|
|
413
422
|
setAgentWatching(!!data.watching);
|
|
@@ -448,6 +457,7 @@ function App() {
|
|
|
448
457
|
annotations: structuredAnnotations,
|
|
449
458
|
generalNote: feedback,
|
|
450
459
|
});
|
|
460
|
+
clearPersistedDraft(localStorage, sessionId, selectedRevision);
|
|
451
461
|
setSharedFeedbackSubmitted(true);
|
|
452
462
|
setToast({ kind: "success", message: "Feedback submitted. The canvas author will see it shortly." });
|
|
453
463
|
} catch (e: any) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { carryUnsubmittedDraft } from "./annotationDraft";
|
|
2
|
+
import { carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
|
|
3
3
|
|
|
4
4
|
class MemoryStorage {
|
|
5
5
|
private readonly values = new Map<string, string>();
|
|
@@ -11,12 +11,27 @@ class MemoryStorage {
|
|
|
11
11
|
setItem(key: string, value: string): void {
|
|
12
12
|
this.values.set(key, value);
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
removeItem(key: string): void {
|
|
16
|
+
this.values.delete(key);
|
|
17
|
+
}
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
const sessionId = "session";
|
|
17
21
|
const key = (revision: number) => `canvas:${sessionId}:rev:${revision}`;
|
|
18
22
|
|
|
19
23
|
describe("carryUnsubmittedDraft", () => {
|
|
24
|
+
test("clears a submitted draft and its carry marker", () => {
|
|
25
|
+
const storage = new MemoryStorage();
|
|
26
|
+
storage.setItem(key(2), JSON.stringify({ generalNote: "Submitted" }));
|
|
27
|
+
storage.setItem(`${key(2)}:draft-handled`, "1");
|
|
28
|
+
|
|
29
|
+
clearPersistedDraft(storage, sessionId, 2);
|
|
30
|
+
|
|
31
|
+
expect(storage.getItem(key(2))).toBeNull();
|
|
32
|
+
expect(storage.getItem(`${key(2)}:draft-handled`)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
20
35
|
test("carries the latest unsent draft into a new revision", () => {
|
|
21
36
|
const storage = new MemoryStorage();
|
|
22
37
|
storage.setItem(key(2), JSON.stringify({
|
|
@@ -8,6 +8,10 @@ interface AnnotationStorage {
|
|
|
8
8
|
setItem(key: string, value: string): void;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
interface RemovableAnnotationStorage {
|
|
12
|
+
removeItem(key: string): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
11
15
|
function storageKey(sessionId: string, revision: number): string {
|
|
12
16
|
return `canvas:${sessionId}:rev:${revision}`;
|
|
13
17
|
}
|
|
@@ -16,6 +20,17 @@ function handledKey(sessionId: string, revision: number): string {
|
|
|
16
20
|
return `${storageKey(sessionId, revision)}:draft-handled`;
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
export function clearPersistedDraft(
|
|
24
|
+
storage: RemovableAnnotationStorage,
|
|
25
|
+
sessionId: string,
|
|
26
|
+
revision: number,
|
|
27
|
+
): void {
|
|
28
|
+
try {
|
|
29
|
+
storage.removeItem(storageKey(sessionId, revision));
|
|
30
|
+
storage.removeItem(handledKey(sessionId, revision));
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
function parseState(raw: string | null): object | null {
|
|
20
35
|
if (!raw) return null;
|
|
21
36
|
try {
|
|
@@ -118,6 +118,47 @@ export function uploadUrl(): string {
|
|
|
118
118
|
return `/api/session/${MODE.sessionId}/upload`;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
function localSecretUrl(fieldId: string, suffix: "status" | "value"): string {
|
|
122
|
+
if (MODE.isShared) throw new Error("Secret inputs are available only on the local canvas daemon");
|
|
123
|
+
return `/api/session/${encodeURIComponent(MODE.sessionId)}/secrets/${encodeURIComponent(fieldId)}/${suffix}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function secretApiError(response: Response): Promise<Error> {
|
|
127
|
+
const raw: unknown = await response.json().catch(() => null);
|
|
128
|
+
if (typeof raw === "object" && raw !== null && "error" in raw && typeof raw.error === "string") {
|
|
129
|
+
return new Error(raw.error);
|
|
130
|
+
}
|
|
131
|
+
return new Error(`Secret API request failed with status ${response.status}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Return only readiness metadata. The secret value is never sent back to the browser. */
|
|
135
|
+
export async function getLocalSecretStatus(fieldId: string): Promise<boolean> {
|
|
136
|
+
const response = await fetch(localSecretUrl(fieldId, "status"), { cache: "no-store" });
|
|
137
|
+
if (!response.ok) throw await secretApiError(response);
|
|
138
|
+
const raw: unknown = await response.json();
|
|
139
|
+
return typeof raw === "object" && raw !== null && "ready" in raw && raw.ready === true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Deposit a secret into the daemon's in-memory vault. */
|
|
143
|
+
export async function storeLocalSecret(fieldId: string, value: string): Promise<void> {
|
|
144
|
+
const response = await fetch(localSecretUrl(fieldId, "value"), {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
cache: "no-store",
|
|
148
|
+
body: JSON.stringify({ value }),
|
|
149
|
+
});
|
|
150
|
+
if (!response.ok) throw await secretApiError(response);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Remove a deposited secret without retrieving its value. */
|
|
154
|
+
export async function clearLocalSecret(fieldId: string): Promise<void> {
|
|
155
|
+
const response = await fetch(localSecretUrl(fieldId, "value"), {
|
|
156
|
+
method: "DELETE",
|
|
157
|
+
cache: "no-store",
|
|
158
|
+
});
|
|
159
|
+
if (!response.ok) throw await secretApiError(response);
|
|
160
|
+
}
|
|
161
|
+
|
|
121
162
|
// --- Meta fetch (with optional decryption) ---------------------------------
|
|
122
163
|
|
|
123
164
|
interface RawMetaResponse {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { useFeedback } from "#canvas/runtime";
|
|
3
|
+
import { MODE, clearLocalSecret, getLocalSecretStatus, storeLocalSecret } from "../clientApi";
|
|
4
|
+
|
|
5
|
+
const FIELD_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
6
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7
|
+
|
|
8
|
+
interface SecretInputProps {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
env: string;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
required?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Local-only secret handoff that never enters annotation or feedback state. */
|
|
17
|
+
export function SecretInput({ id, label, env, placeholder, required }: SecretInputProps) {
|
|
18
|
+
const [value, setValue] = useState("");
|
|
19
|
+
const [ready, setReady] = useState(false);
|
|
20
|
+
const [busy, setBusy] = useState(!MODE.isShared);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const validFieldId = FIELD_ID_RE.test(id);
|
|
23
|
+
const validEnvName = ENV_NAME_RE.test(env);
|
|
24
|
+
const locallyAvailable = !MODE.isShared;
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!locallyAvailable || !validFieldId) {
|
|
28
|
+
setBusy(false);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let active = true;
|
|
33
|
+
getLocalSecretStatus(id)
|
|
34
|
+
.then((isReady) => {
|
|
35
|
+
if (active) setReady(isReady);
|
|
36
|
+
})
|
|
37
|
+
.catch((cause: unknown) => {
|
|
38
|
+
if (active) setError(cause instanceof Error ? cause.message : "Could not read secret status");
|
|
39
|
+
})
|
|
40
|
+
.finally(() => {
|
|
41
|
+
if (active) setBusy(false);
|
|
42
|
+
});
|
|
43
|
+
return () => { active = false; };
|
|
44
|
+
}, [id, locallyAvailable, validFieldId]);
|
|
45
|
+
|
|
46
|
+
const feedback = ready
|
|
47
|
+
? `**${label}**\nSecret is ready. Field: \`${id}\`. Environment variable: \`${env}\`.\n`
|
|
48
|
+
: "";
|
|
49
|
+
useFeedback(`secret:${id}`, feedback, {
|
|
50
|
+
label,
|
|
51
|
+
required: required && locallyAvailable,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const store = async () => {
|
|
55
|
+
if (!value || !validFieldId || !validEnvName) return;
|
|
56
|
+
setBusy(true);
|
|
57
|
+
setError(null);
|
|
58
|
+
try {
|
|
59
|
+
await storeLocalSecret(id, value);
|
|
60
|
+
setValue("");
|
|
61
|
+
setReady(true);
|
|
62
|
+
} catch (cause: unknown) {
|
|
63
|
+
setError(cause instanceof Error ? cause.message : "Could not store secret");
|
|
64
|
+
} finally {
|
|
65
|
+
setBusy(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const clear = async () => {
|
|
70
|
+
setBusy(true);
|
|
71
|
+
setError(null);
|
|
72
|
+
try {
|
|
73
|
+
await clearLocalSecret(id);
|
|
74
|
+
setReady(false);
|
|
75
|
+
} catch (cause: unknown) {
|
|
76
|
+
setError(cause instanceof Error ? cause.message : "Could not clear secret");
|
|
77
|
+
} finally {
|
|
78
|
+
setBusy(false);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (!locallyAvailable) {
|
|
83
|
+
return (
|
|
84
|
+
<div data-md="secretinput" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg bg-accent-amber-muted">
|
|
85
|
+
<div className="text-[13px] font-body font-medium text-text-primary">{label}</div>
|
|
86
|
+
<p className="text-[12px] font-body text-text-secondary mt-1">
|
|
87
|
+
Secret input is unavailable in shared canvases. Open this runbook on the author's local canvas daemon.
|
|
88
|
+
</p>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const configurationError = !validFieldId
|
|
94
|
+
? "Secret field IDs may contain letters, numbers, dots, underscores, and hyphens."
|
|
95
|
+
: !validEnvName
|
|
96
|
+
? "Environment variable names must use letters, numbers, and underscores and may not start with a number."
|
|
97
|
+
: null;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div data-md="secretinput" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg transition-colors duration-150 hover:bg-bg-input">
|
|
101
|
+
<div className="flex items-baseline justify-between gap-3 mb-2">
|
|
102
|
+
<div className="flex items-baseline gap-2">
|
|
103
|
+
<span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
|
|
104
|
+
{required && <span className="text-[10px] text-accent-red font-body">*</span>}
|
|
105
|
+
</div>
|
|
106
|
+
<span className={`text-[11px] font-body ${ready ? "text-accent-green" : "text-text-tertiary"}`}>
|
|
107
|
+
{busy ? "Working…" : ready ? "Ready in memory" : "Not stored"}
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div className="flex gap-2">
|
|
112
|
+
<input
|
|
113
|
+
type="password"
|
|
114
|
+
value={value}
|
|
115
|
+
onChange={(event) => setValue(event.target.value)}
|
|
116
|
+
onKeyDown={(event) => {
|
|
117
|
+
if (event.key === "Enter") {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
void store();
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
placeholder={placeholder || "Paste secret value"}
|
|
123
|
+
autoComplete="off"
|
|
124
|
+
spellCheck={false}
|
|
125
|
+
disabled={busy || !!configurationError}
|
|
126
|
+
className="min-w-0 flex-1 bg-bg-input text-[13px] font-mono text-text-primary rounded-lg px-3 py-2 focus:outline-none border border-border-subtle focus:border-border-hover placeholder:text-text-tertiary disabled:opacity-50 transition-colors"
|
|
127
|
+
/>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={() => void store()}
|
|
131
|
+
disabled={busy || !value || !!configurationError}
|
|
132
|
+
className="px-3 py-2 rounded-lg bg-btn-primary text-btn-primary-text text-[12px] font-body font-medium disabled:opacity-40 disabled:cursor-default"
|
|
133
|
+
>
|
|
134
|
+
{ready ? "Replace" : "Store"}
|
|
135
|
+
</button>
|
|
136
|
+
{ready && (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={() => void clear()}
|
|
140
|
+
disabled={busy}
|
|
141
|
+
className="px-3 py-2 rounded-lg border border-border-subtle text-text-secondary text-[12px] font-body font-medium hover:border-border-hover disabled:opacity-40"
|
|
142
|
+
>
|
|
143
|
+
Clear
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<p className="text-[11px] font-body text-text-tertiary mt-1.5">
|
|
149
|
+
Stored only in daemon memory. The agent receives field {id} for {env}, not the value.
|
|
150
|
+
</p>
|
|
151
|
+
{(configurationError || error) && (
|
|
152
|
+
<p className="text-[11px] text-accent-red font-body mt-1">{configurationError || error}</p>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -11,6 +11,7 @@ export { Mermaid } from "./Mermaid";
|
|
|
11
11
|
export { Diff } from "./Diff";
|
|
12
12
|
export { Choice, MultiChoice } from "./Choice";
|
|
13
13
|
export { UserInput, RangeInput } from "./UserInput";
|
|
14
|
+
export { SecretInput } from "./SecretInput";
|
|
14
15
|
export { ImageView } from "./Image";
|
|
15
16
|
export { Markdown } from "./Markdown";
|
|
16
17
|
export { useFeedback } from "#canvas/runtime";
|