agent-canvas 0.1.41 → 0.1.43

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 (40) hide show
  1. package/README.md +18 -1
  2. package/bin/agent-canvas.ts +5 -1
  3. package/bin/lib/commands/exec.test.ts +27 -0
  4. package/bin/lib/commands/exec.ts +129 -0
  5. package/bin/lib/commands/watch.ts +8 -1
  6. package/bin/lib/config.ts +1 -0
  7. package/bin/lib/daemon-lifecycle.ts +3 -1
  8. package/bin/lib/helpers.test.ts +78 -0
  9. package/bin/lib/helpers.ts +92 -15
  10. package/bin/lib/instructions/component-secretinput.md +61 -0
  11. package/bin/lib/instructions/flow-runbook.md +77 -0
  12. package/bin/lib/instructions/overview.md +8 -0
  13. package/daemon/client/AnnotationProvider.tsx +2 -7
  14. package/daemon/client/App.tsx +49 -13
  15. package/daemon/client/PlanRenderer.tsx +67 -10
  16. package/daemon/client/RenderErrorContext.ts +11 -0
  17. package/daemon/client/annotationDraft.test.ts +16 -1
  18. package/daemon/client/annotationDraft.ts +15 -0
  19. package/daemon/client/clientApi.ts +41 -0
  20. package/daemon/client/components/SecretInput.tsx +156 -0
  21. package/daemon/client/components/index.ts +1 -0
  22. package/daemon/dist/client.css +1 -1
  23. package/daemon/dist/client.js +39 -39
  24. package/daemon/dist/components.js +42 -40
  25. package/daemon/src/compiler.test.ts +18 -0
  26. package/daemon/src/compiler.ts +40 -6
  27. package/daemon/src/handlers/api.ts +81 -0
  28. package/daemon/src/handlers/utils.ts +1 -1
  29. package/daemon/src/paths.ts +3 -0
  30. package/daemon/src/secrets-api.test.ts +123 -0
  31. package/daemon/src/server.ts +9 -5
  32. package/daemon/src/session-secrets.test.ts +83 -0
  33. package/daemon/src/session.ts +55 -11
  34. package/daemon/src/websocket.test.ts +99 -0
  35. package/daemon/src/websocket.ts +64 -10
  36. package/package.json +1 -1
  37. package/skills/canvas/SKILL.md +2 -1
  38. package/workers/canvas-share/public/assets/client.css +1 -1
  39. package/workers/canvas-share/public/assets/client.js +39 -39
  40. package/workers/canvas-share/public/assets/components.js +42 -40
@@ -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
- clearPersisted(sessionId, revision);
162
+ clearPersistedDraft(localStorage, sessionId, revision);
168
163
  }, [sessionId, revision]);
169
164
 
170
165
  return (
@@ -21,7 +21,8 @@ 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
+ import { RenderErrorContext, type CanvasRenderError } from "./RenderErrorContext";
25
26
 
26
27
  export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
27
28
 
@@ -232,6 +233,8 @@ function App() {
232
233
  const [activeView, setActiveViewRaw] = useState<ActiveView>({ type: "overview" });
233
234
  const [openFiles, setOpenFiles] = useState<string[]>([]);
234
235
  const wsRef = useRef<WebSocket | null>(null);
236
+ const pendingRenderErrorsRef = useRef<CanvasRenderError[]>([]);
237
+ const reportedRenderErrorsRef = useRef(new Set<string>());
235
238
 
236
239
  const [projectRoot, setProjectRoot] = useState<string | undefined>();
237
240
  const [mobileSidebar, setMobileSidebar] = useState(false);
@@ -247,6 +250,21 @@ function App() {
247
250
  const [reviewerDialogOpen, setReviewerDialogOpen] = useState(false);
248
251
  const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
249
252
  const [toast, setToast] = useState<{ kind: "error" | "success"; message: string } | null>(null);
253
+
254
+ const reportRenderError = useCallback((error: CanvasRenderError) => {
255
+ if (isSharedMode) return;
256
+ const signature = `${error.revision}\0${error.filename}\0${error.message}`;
257
+ if (reportedRenderErrorsRef.current.has(signature)) return;
258
+ reportedRenderErrorsRef.current.add(signature);
259
+
260
+ const payload = JSON.stringify({ type: "render-error", error });
261
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
262
+ wsRef.current.send(payload);
263
+ } else {
264
+ pendingRenderErrorsRef.current.push(error);
265
+ }
266
+ }, [isSharedMode]);
267
+
250
268
  useEffect(() => {
251
269
  if (!toast) return;
252
270
  const t = setTimeout(() => setToast(null), 4000);
@@ -314,6 +332,9 @@ function App() {
314
332
  .then((data: any) => {
315
333
  if (data.currentRevision) {
316
334
  const nextRevisions: RevisionInfo[] = data.revisions || [];
335
+ for (const revision of nextRevisions) {
336
+ if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision);
337
+ }
317
338
  carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, nextRevisions);
318
339
  setCurrentRevision(data.currentRevision);
319
340
  setSelectedRevision(data.currentRevision);
@@ -375,7 +396,13 @@ function App() {
375
396
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
376
397
  const ws = new WebSocket(`${protocol}//${window.location.host}/ws/session/${sessionId}`);
377
398
  wsRef.current = ws;
378
- ws.onopen = () => setConnected(true);
399
+ ws.onopen = () => {
400
+ setConnected(true);
401
+ for (const error of pendingRenderErrorsRef.current) {
402
+ ws.send(JSON.stringify({ type: "render-error", error }));
403
+ }
404
+ pendingRenderErrorsRef.current = [];
405
+ };
379
406
  ws.onclose = () => { setConnected(false); reconnectTimer = setTimeout(connect, 2000); };
380
407
  ws.onmessage = (event) => {
381
408
  try {
@@ -407,7 +434,13 @@ function App() {
407
434
  }
408
435
  }
409
436
  if (data.type === "revision-updated") {
410
- if (data.revisions) setRevisions(data.revisions);
437
+ if (Array.isArray(data.revisions)) {
438
+ const nextRevisions: RevisionInfo[] = data.revisions;
439
+ for (const revision of nextRevisions) {
440
+ if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision);
441
+ }
442
+ setRevisions(nextRevisions);
443
+ }
411
444
  }
412
445
  if (data.type === "watcher-status") {
413
446
  setAgentWatching(!!data.watching);
@@ -448,6 +481,7 @@ function App() {
448
481
  annotations: structuredAnnotations,
449
482
  generalNote: feedback,
450
483
  });
484
+ clearPersistedDraft(localStorage, sessionId, selectedRevision);
451
485
  setSharedFeedbackSubmitted(true);
452
486
  setToast({ kind: "success", message: "Feedback submitted. The canvas author will see it shortly." });
453
487
  } catch (e: any) {
@@ -482,14 +516,15 @@ function App() {
482
516
 
483
517
  return (
484
518
  <SessionContext.Provider value={sessionId}>
485
- <RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
486
- <AnnotationProvider
487
- key={`${sessionId}:${selectedRevision}`}
488
- sessionId={sessionId}
489
- revision={selectedRevision}
490
- isReadOnly={isReadOnly}
491
- remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
492
- >
519
+ <RenderErrorContext.Provider value={reportRenderError}>
520
+ <RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
521
+ <AnnotationProvider
522
+ key={`${sessionId}:${selectedRevision}`}
523
+ sessionId={sessionId}
524
+ revision={selectedRevision}
525
+ isReadOnly={isReadOnly}
526
+ remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
527
+ >
493
528
  <ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
494
529
  <ActiveViewCtx.Provider value={{ setActiveView }}>
495
530
  <div className="min-h-screen bg-bg-base">
@@ -663,8 +698,9 @@ function App() {
663
698
  </div>
664
699
  </ActiveViewCtx.Provider>
665
700
  </ActiveViewContext.Provider>
666
- </AnnotationProvider>
667
- </RevisionContext.Provider>
701
+ </AnnotationProvider>
702
+ </RevisionContext.Provider>
703
+ </RenderErrorContext.Provider>
668
704
  </SessionContext.Provider>
669
705
  );
670
706
  }
@@ -7,6 +7,7 @@ import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
7
7
  import { generateAnnotationId } from "./utils";
8
8
  import { useTextAnnotation } from "./useTextAnnotation";
9
9
  import { loadCanvasModule } from "./clientApi";
10
+ import { RenderErrorContext, type CanvasRenderError } from "./RenderErrorContext";
10
11
 
11
12
  /** All navigable blocks (keyboard arrows) */
12
13
  const BLOCK_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='choice-option'], [data-md='multichoice-option'], [data-md='userinput'], [data-md='rangeinput'], [data-md='image']";
@@ -18,8 +19,53 @@ interface PlanRendererProps {
18
19
  filename: string;
19
20
  }
20
21
 
22
+ interface CanvasErrorBoundaryProps {
23
+ revision: number;
24
+ filename: string;
25
+ reportError: (error: CanvasRenderError) => void;
26
+ children: React.ReactNode;
27
+ }
28
+
29
+ interface CanvasErrorBoundaryState {
30
+ error: Error | null;
31
+ }
32
+
33
+ class CanvasErrorBoundary extends React.Component<CanvasErrorBoundaryProps, CanvasErrorBoundaryState> {
34
+ state: CanvasErrorBoundaryState = { error: null };
35
+
36
+ static getDerivedStateFromError(error: Error): CanvasErrorBoundaryState {
37
+ return { error };
38
+ }
39
+
40
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
41
+ this.props.reportError({
42
+ revision: this.props.revision,
43
+ filename: this.props.filename,
44
+ message: error.message,
45
+ ...(error.stack ? { stack: error.stack } : {}),
46
+ ...(info.componentStack ? { componentStack: info.componentStack } : {}),
47
+ });
48
+ }
49
+
50
+ render() {
51
+ if (!this.state.error) return this.props.children;
52
+ return <CanvasErrorDisplay message={this.state.error.message} />;
53
+ }
54
+ }
55
+
56
+ function CanvasErrorDisplay({ message }: { message: string }) {
57
+ return (
58
+ <div className="bg-accent-red-muted rounded-lg p-5">
59
+ <h3 className="font-body font-semibold text-accent-red mb-2">Canvas render failed</h3>
60
+ <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{message}</pre>
61
+ <p className="text-meta font-body text-text-tertiary mt-3">The error was sent back to the canvas author.</p>
62
+ </div>
63
+ );
64
+ }
65
+
21
66
  export function PlanRenderer({ revision, filename }: PlanRendererProps) {
22
67
  const sessionId = useContext(SessionContext);
68
+ const reportError = useContext(RenderErrorContext);
23
69
  const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
24
70
  const [error, setError] = useState<string | null>(null);
25
71
  const [loading, setLoading] = useState(true);
@@ -44,8 +90,18 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
44
90
  void sessionId; // sessionId is implied by the URL in clientApi (shared or local)
45
91
  loadCanvasModule(filename, revision)
46
92
  .then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
47
- .catch((e) => { setError(e.message); setLoading(false); });
48
- }, [sessionId, revision, filename]);
93
+ .catch((cause: unknown) => {
94
+ const moduleError = cause instanceof Error ? cause : new Error(String(cause));
95
+ setError(moduleError.message);
96
+ setLoading(false);
97
+ reportError({
98
+ revision,
99
+ filename,
100
+ message: moduleError.message,
101
+ ...(moduleError.stack ? { stack: moduleError.stack } : {}),
102
+ });
103
+ });
104
+ }, [sessionId, revision, filename, reportError]);
49
105
 
50
106
  const scrollContainer = document.getElementById("plan-scroll-container");
51
107
 
@@ -250,12 +306,7 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
250
306
  }
251
307
 
252
308
  if (error) {
253
- return (
254
- <div className="bg-accent-red-muted rounded-lg p-5">
255
- <h3 className="font-body font-semibold text-accent-red mb-2">Compilation Error</h3>
256
- <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{error}</pre>
257
- </div>
258
- );
309
+ return <CanvasErrorDisplay message={error} />;
259
310
  }
260
311
 
261
312
  if (!PlanComponent) {
@@ -265,7 +316,14 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
265
316
  return (
266
317
  <CanvasFileCtx.Provider value={filename}>
267
318
  <div ref={containerRef} className="plan-content plan-updated">
268
- {planElement}
319
+ <CanvasErrorBoundary
320
+ key={`${revision}:${filename}`}
321
+ revision={revision}
322
+ filename={filename}
323
+ reportError={reportError}
324
+ >
325
+ {planElement}
326
+ </CanvasErrorBoundary>
269
327
  </div>
270
328
  {editingAnn && (
271
329
  <EditAnnotationModal
@@ -554,4 +612,3 @@ function EditAnnotationModal({ note, onSave, onCancel }: { note: string; onSave:
554
612
  </div>
555
613
  );
556
614
  }
557
-
@@ -0,0 +1,11 @@
1
+ import { createContext } from "react";
2
+
3
+ export interface CanvasRenderError {
4
+ revision: number;
5
+ filename: string;
6
+ message: string;
7
+ stack?: string;
8
+ componentStack?: string;
9
+ }
10
+
11
+ export const RenderErrorContext = createContext<(error: CanvasRenderError) => void>(() => {});
@@ -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";