From 87e0ccf8c5be5a10b20129db642395ea97da1793 Mon Sep 17 00:00:00 2001 From: ayattara Date: Tue, 11 Aug 2026 14:21:15 +0000 Subject: [PATCH 01/14] feat: generalize notes storage to workspace scope, add sidebar button createNoteStore, the card-indicator cache, and the modal factory take a (scope, scopeId) pair instead of a hardcoded "task" scope (taskId remains a working alias, so every existing task surface is unaffected). Registers a new sidebar-workspace-actions button that opens the same note modal scoped to the active workspace instead of a task, inert on hosts that don't carry that slot yet. --- ui/bundle.js | 376 ++++++++++++++++++++++++++++++++++----------- ui/bundle.test.mjs | 255 +++++++++++++++++++++++++++++- 2 files changed, 536 insertions(+), 95 deletions(-) diff --git a/ui/bundle.js b/ui/bundle.js index f3faebd..bf84611 100644 --- a/ui/bundle.js +++ b/ui/bundle.js @@ -48,7 +48,11 @@ // the operator-configured utility agent (README's Privacy section explains // this trade-off). -const NOTE_SCOPE = "task"; +// DEFAULT_SCOPE is the store/cache default when no scope is given, keeping +// every pre-existing task-scoped caller (panel, mobile panel, kanban modal, +// card indicator) working unchanged. A workspace note passes scope: +// "workspace" explicitly instead. +const DEFAULT_SCOPE = "task"; const NOTE_KEY = "note"; const WRITE_DEBOUNCE_MS = 150; const ENHANCE_WEBHOOK_PATH = "webhooks/enhance"; @@ -289,7 +293,10 @@ export async function enhanceNote(host, content) { // 412 is this webhook's distinguishable "no utility agent configured" // signal (server/plugin.go, mapped from gRPC FailedPrecondition per // ADR 0048) — surfaced as a clear, non-fatal message rather than a - // generic failure. + // generic failure. `code`/`detail` (C1) let the UI point at the right + // settings page instead of one message covering unset/missing/disabled + // alike; an older server that omits them (C5) leaves both undefined and + // the caller falls back to the plain message with no action button. const notConfigured = response.status === 412; const message = notConfigured ? (data && data.error) || "No utility agent is configured for this plugin yet." @@ -297,6 +304,8 @@ export async function enhanceNote(host, content) { const error = new Error(message); error.notConfigured = notConfigured; error.status = response.status; + error.code = data && typeof data.code === "string" ? data.code : undefined; + error.detail = data && typeof data.detail === "string" ? data.detail : undefined; throw error; } @@ -328,7 +337,12 @@ export function enhancePreviewReducer(state, action) { case "success": return { status: "preview", preview: action.content }; case "failure": - return { status: "error", message: action.message, notConfigured: Boolean(action.notConfigured) }; + return { + status: "error", + message: action.message, + notConfigured: Boolean(action.notConfigured), + code: action.code, + }; case "discard": case "accept": case "dismiss": @@ -338,6 +352,29 @@ export function enhancePreviewReducer(state, action) { } } +// enhanceErrorAction (C2/C4) maps an enhance failure's `code` to the guided +// setup action NotesEditor's error branch renders beside Dismiss: which +// settings page fixes *this* cause, in its own words. "unset"/"missing" both +// land on the Notes plugin page (pick or re-pick an agent); "disabled" lands +// on Utility Agents instead — a different page, because picking an agent +// there again would not fix a merely-disabled one (see server/plugin.go's +// classifyUtilityAgentError comment for the host-side half of this split). +// A pure function (no host, no React) so C7's code -> action mapping is +// testable directly; returns null for an absent/unrecognized code (C5: an +// older server that omits `code`, or "agent_unavailable", renders no button +// — the message alone is what's known). +export function enhanceErrorAction(code) { + switch (code) { + case "agent_unset": + case "agent_missing": + return { label: "Choose an agent", href: "/settings/plugins/kandev-plugin-notes" }; + case "agent_disabled": + return { label: "Enable the agent", href: "/settings/utility-agents" }; + default: + return null; + } +} + // --------------------------------------------------------------------------- // describeReadError — classifies a rejected host.storage.get() into a // snapshot-safe shape ({ status, message, retryable, detail }) so the read @@ -417,15 +454,22 @@ export function describeReadError(error) { // - a PluginStorageConflictError (409) stops the write queue, preserves // the caller's in-flight edit, and only refreshes the authoritative // updatedAt — it never silently discards the edit. -// - setTaskId() clears value/updatedAt synchronously (before the new -// task's read resolves) so a write in flight for the old task can never -// be sent under the new task's id with a stale ifUnmodifiedSince. +// - setScopeId() (alias: setTaskId()) clears value/updatedAt synchronously +// (before the new scopeId's read resolves) so a write in flight for the +// old scopeId can never be sent under the new one's id with a stale +// ifUnmodifiedSince. +// +// scope defaults to "task" (and scopeId falls back to the legacy `taskId` +// option) so every pre-existing caller is unaffected; a workspace note store +// passes { scope: "workspace", scopeId: workspaceId } instead. scope itself +// is fixed for a store's lifetime — only scopeId changes via setScopeId. // --------------------------------------------------------------------------- -export function createNoteStore(host, { taskId, surfaceId, onCommit }) { - let currentTaskId = taskId; +export function createNoteStore(host, { scope = DEFAULT_SCOPE, scopeId, taskId, surfaceId, onCommit }) { + const currentScope = scope; + let currentScopeId = scopeId ?? taskId; let value = ""; let updatedAt; - let loadedTaskId = null; + let loadedScopeId = null; let readError = null; let dirty = false; let conflict = false; @@ -458,14 +502,14 @@ export function createNoteStore(host, { taskId, surfaceId, onCommit }) { } } - function scheduleAutoRetry(forTaskId) { + function scheduleAutoRetry(forScopeId) { if (!readError || !readError.retryable) return; if (autoRetryCount >= AUTO_RETRY_LIMIT) return; autoRetryCount += 1; const delay = AUTO_RETRY_BASE_MS * 2 ** (autoRetryCount - 1); autoRetryTimer = setTimeout(() => { autoRetryTimer = undefined; - if (disposed || forTaskId !== currentTaskId) return; + if (disposed || forScopeId !== currentScopeId) return; refresh(); }, delay); } @@ -476,14 +520,14 @@ export function createNoteStore(host, { taskId, surfaceId, onCommit }) { // logical read as a raw host.api.fetch so the snapshot can carry the real // response.status and any JSON error body instead of a regex guess. Fires // at most once per rejected refresh(); a probe for a superseded - // generation/taskId is dropped by the same guard refresh() itself uses, + // generation/scopeId is dropped by the same guard refresh() itself uses, // and a probe that itself fails just leaves the describeReadError // classification in place. - function issueReadErrorProbe(rawError, generation, forTaskId) { + function issueReadErrorProbe(rawError, generation, forScopeId) { const fetchApi = host.api && host.api.fetch; const probe = typeof fetchApi === "function" - ? Promise.resolve(fetchApi(`user-state/${NOTE_SCOPE}/${forTaskId}/${NOTE_KEY}`)).then( + ? Promise.resolve(fetchApi(`user-state/${currentScope}/${forScopeId}/${NOTE_KEY}`)).then( async (response) => { let body = null; try { @@ -502,7 +546,7 @@ export function createNoteStore(host, { taskId, surfaceId, onCommit }) { : Promise.resolve(undefined); probe.then((probeInfo) => { - if (!disposed && generation === refreshGeneration && forTaskId === currentTaskId && probeInfo) { + if (!disposed && generation === refreshGeneration && forScopeId === currentScopeId && probeInfo) { readError = { ...readError, status: probeInfo.status, detail: probeInfo.detail }; notify(); } @@ -522,9 +566,11 @@ export function createNoteStore(host, { taskId, surfaceId, onCommit }) { function getSnapshot() { return { - taskId: currentTaskId, + scope: currentScope, + scopeId: currentScopeId, + taskId: currentScope === "task" ? currentScopeId : null, value, - loaded: loadedTaskId === currentTaskId, + loaded: loadedScopeId === currentScopeId, readError, conflict, writeError, @@ -537,13 +583,13 @@ export function createNoteStore(host, { taskId, surfaceId, onCommit }) { function refresh(options = {}) { const preserveValue = options.preserveValue ?? dirty; const generation = ++refreshGeneration; - const forTaskId = currentTaskId; - return host.storage.get(NOTE_SCOPE, forTaskId, NOTE_KEY).then( + const forScopeId = currentScopeId; + return host.storage.get(currentScope, forScopeId, NOTE_KEY).then( (entry) => { - if (disposed || generation !== refreshGeneration || forTaskId !== currentTaskId) return false; + if (disposed || generation !== refreshGeneration || forScopeId !== currentScopeId) return false; if (!preserveValue && !dirty) value = entry ? entry.value : ""; updatedAt = entry ? entry.updatedAt : undefined; - loadedTaskId = forTaskId; + loadedScopeId = forScopeId; readError = null; autoRetryCount = 0; clearAutoRetryTimer(); @@ -551,14 +597,14 @@ export function createNoteStore(host, { taskId, surfaceId, onCommit }) { return true; }, (rawError) => { - // Do not mark the task loaded after a rejected read: an empty + // Do not mark this scopeId loaded after a rejected read: an empty // editor here could omit ifUnmodifiedSince on its first save and // silently overwrite an existing note. Stay in a retry state. - if (disposed || generation !== refreshGeneration || forTaskId !== currentTaskId) return false; + if (disposed || generation !== refreshGeneration || forScopeId !== currentScopeId) return false; readError = describeReadError(rawError); notify(); - scheduleAutoRetry(forTaskId); - issueReadErrorProbe(rawError, generation, forTaskId); + scheduleAutoRetry(forScopeId); + issueReadErrorProbe(rawError, generation, forScopeId); return false; }, ); @@ -566,9 +612,9 @@ export function createNoteStore(host, { taskId, surfaceId, onCommit }) { function subscribeStorage() { if (unsubscribeStorage) unsubscribeStorage(); - const forTaskId = currentTaskId; + const forScopeId = currentScopeId; unsubscribeStorage = host.storage.subscribe( - { scope: NOTE_SCOPE, scopeId: forTaskId, key: NOTE_KEY, writerId: surfaceId }, + { scope: currentScope, scopeId: forScopeId, key: NOTE_KEY, writerId: surfaceId }, () => refresh(), ); } @@ -586,12 +632,12 @@ export function createNoteStore(host, { taskId, surfaceId, onCommit }) { } } - function setTaskId(nextTaskId) { - if (nextTaskId === currentTaskId) return; - currentTaskId = nextTaskId; + function setScopeId(nextScopeId) { + if (nextScopeId === currentScopeId) return; + currentScopeId = nextScopeId; value = ""; updatedAt = undefined; - loadedTaskId = null; + loadedScopeId = null; readError = null; autoRetryCount = 0; clearAutoRetryTimer(); @@ -621,12 +667,12 @@ export function createNoteStore(host, { taskId, surfaceId, onCommit }) { // existing note (see refresh()'s reject handler above). if (writeBlocked || writeInFlight || pendingValue === undefined || readError) return; const generation = writeGeneration; - const forTaskId = currentTaskId; + const forScopeId = currentScopeId; const next = pendingValue; pendingValue = undefined; writeInFlight = true; host.storage - .set(NOTE_SCOPE, forTaskId, NOTE_KEY, next, { writerId: surfaceId, ifUnmodifiedSince: updatedAt }) + .set(currentScope, forScopeId, NOTE_KEY, next, { writerId: surfaceId, ifUnmodifiedSince: updatedAt }) .then((result) => { if (writeGeneration !== generation) return; updatedAt = result.updatedAt; @@ -700,7 +746,11 @@ export function createNoteStore(host, { taskId, surfaceId, onCommit }) { }, getSnapshot, setValue, - setTaskId, + setScopeId, + // setTaskId: alias for setScopeId, kept for the "task" scope's existing + // callers (useNoteStore's own effect, and any external caller written + // before scope existed) — same synchronous clear-before-read guarantee. + setTaskId: setScopeId, retryRead, retryWrite, dispose, @@ -720,12 +770,17 @@ const activeStores = new Set(); // --------------------------------------------------------------------------- // Card-indicator cache — module-level so it survives route navigation and is // shared by every rendered card, per AC17: at most one host.storage.get per -// taskId per page session. A second render serves the cache; a cross-tab -// subscribe notification updates it from the notification's `deleted` flag -// alone (no refetch — the payload carries no value); and a successful local -// write updates it directly, since own-tab echoes of that write are -// suppressed by the writerId-scoped subscription above and would never -// reach this module-level listener otherwise. +// (scope, scopeId) per page session. A second render serves the cache; a +// cross-tab subscribe notification updates it from the notification's +// `deleted` flag alone (no refetch — the payload carries no value); and a +// successful local write updates it directly, since own-tab echoes of that +// write are suppressed by the writerId-scoped subscription above and would +// never reach this module-level listener otherwise. +// +// Keyed by `${scope}:${scopeId}` (cacheKey below) so a task and a workspace +// that happen to share a raw id never collide — every exported helper below +// takes a `scope` argument defaulting to "task", the pre-existing (and only, +// before workspace notes) caller. // // A cross-tab *non-delete* notification is optimistically treated as "has a // note" without inspecting content, since PluginUserStateChange carries no @@ -738,63 +793,73 @@ const pendingGets = new Map(); const cacheListeners = new Map(); let indicatorUnsubscribe = null; -function notifyCacheListeners(taskId) { - const listeners = cacheListeners.get(taskId); +function cacheKey(scope, scopeId) { + return `${scope}:${scopeId}`; +} + +function notifyCacheListeners(key) { + const listeners = cacheListeners.get(key); if (!listeners) return; - const hasNote = noteCache.get(taskId) ?? false; + const hasNote = noteCache.get(key) ?? false; listeners.forEach((listener) => listener(hasNote)); } -export function markNote(taskId, hasNote) { - noteCache.set(taskId, hasNote); - notifyCacheListeners(taskId); +export function markNote(scopeId, hasNote, scope = DEFAULT_SCOPE) { + const key = cacheKey(scope, scopeId); + noteCache.set(key, hasNote); + notifyCacheListeners(key); } -export function getCachedHasNote(host, taskId) { - if (noteCache.has(taskId)) return Promise.resolve(noteCache.get(taskId)); - const pending = pendingGets.get(taskId); +export function getCachedHasNote(host, scopeId, scope = DEFAULT_SCOPE) { + const key = cacheKey(scope, scopeId); + if (noteCache.has(key)) return Promise.resolve(noteCache.get(key)); + const pending = pendingGets.get(key); if (pending) return pending; - const request = host.storage.get(NOTE_SCOPE, taskId, NOTE_KEY).then( + const request = host.storage.get(scope, scopeId, NOTE_KEY).then( (entry) => { - pendingGets.delete(taskId); + pendingGets.delete(key); const hasNote = Boolean(entry && typeof entry.value === "string" && entry.value !== ""); - noteCache.set(taskId, hasNote); - notifyCacheListeners(taskId); + noteCache.set(key, hasNote); + notifyCacheListeners(key); return hasNote; }, () => { - // Leave this taskId uncached on a failed read so a later render can + // Leave this key uncached on a failed read so a later render can // retry, instead of pinning it to a possibly-wrong false forever. - pendingGets.delete(taskId); + pendingGets.delete(key); return false; }, ); - pendingGets.set(taskId, request); + pendingGets.set(key, request); return request; } -export function subscribeCache(taskId, listener) { - let listeners = cacheListeners.get(taskId); +export function subscribeCache(scopeId, listener, scope = DEFAULT_SCOPE) { + const key = cacheKey(scope, scopeId); + let listeners = cacheListeners.get(key); if (!listeners) { listeners = new Set(); - cacheListeners.set(taskId, listeners); + cacheListeners.set(key, listeners); } listeners.add(listener); return () => { listeners.delete(listener); - if (listeners.size === 0) cacheListeners.delete(taskId); + if (listeners.size === 0) cacheListeners.delete(key); }; } // initNoteIndicatorSubscription is called from initialize() every time the // plugin is (re-)enabled. It tears down any prior subscription first so // calling initialize() twice in one tab (disable -> re-enable) still leaves -// exactly one module-level subscription, never two. +// exactly one module-level subscription, never two. No `scope` filter here +// (deliberately, unlike a single store's own subscribeStorage): one +// subscription must see every scope's note changes, task and workspace +// alike, so a workspace note write also flips its sidebar-button cache entry. export function initNoteIndicatorSubscription(host) { if (indicatorUnsubscribe) indicatorUnsubscribe(); - indicatorUnsubscribe = host.storage.subscribe({ scope: NOTE_SCOPE, key: NOTE_KEY }, (change) => { - markNote(change.scopeId, !change.deleted); + indicatorUnsubscribe = host.storage.subscribe({ key: NOTE_KEY }, (change) => { + markNote(change.scopeId, !change.deleted, change.scope); }); } @@ -828,15 +893,25 @@ export function disposeNoteIndicatorSubscription() { // not render one — confirmed live (empty, uneditable modal body). That is // host-platform code this repo cannot change, so the modal keeps the // textarea+toolbar fallback until the host wraps plugin modals in one. +// +// { scope, scopeId } generalizes this factory beyond the task modal — the +// workspace sidebar button (openWorkspaceNoteModal) reuses it unchanged with +// scope: "workspace". `close`, when given, is the owning PluginModalHandle's +// close() (see openScopedNoteModal below) — NotesEditor's enhance-error +// action (C4) closes the modal before navigating away from it. // --------------------------------------------------------------------------- -export function makeNoteModalContent(host, taskId) { +export function makeNoteModalContent(host, { scope = DEFAULT_SCOPE, scopeId, taskId } = {}, close) { + const resolvedScopeId = scopeId ?? taskId; return function NoteModalContent() { const { jsx: h } = host; return h(NotesEditor, { host, - taskId, + scope, + scopeId: resolvedScopeId, + taskId: scope === DEFAULT_SCOPE ? resolvedScopeId : undefined, surfaceId: "note-modal", presentation: "modal", + onCloseModal: close, }); }; } @@ -845,16 +920,21 @@ export function makeNoteModalContent(host, taskId) { // React layer. Deliberately thin: all the guard logic above is framework- // free and unit-tested directly; these components only subscribe to it. // --------------------------------------------------------------------------- -function useNoteStore(host, { taskId, surfaceId }) { +function useNoteStore(host, { scope = DEFAULT_SCOPE, scopeId, taskId, surfaceId }) { const React = host.React; + const resolvedScopeId = scopeId ?? taskId; const storeRef = React.useRef(null); const [snapshot, setSnapshot] = React.useState(null); React.useEffect(() => { const store = createNoteStore(host, { - taskId, + scope, + scopeId: resolvedScopeId, surfaceId, - onCommit: (hasNote) => markNote(store.getSnapshot().taskId, hasNote), + onCommit: (hasNote) => { + const snap = store.getSnapshot(); + markNote(snap.scopeId, hasNote, snap.scope); + }, }); storeRef.current = store; setSnapshot(store.getSnapshot()); @@ -864,15 +944,15 @@ function useNoteStore(host, { taskId, surfaceId }) { store.dispose(); storeRef.current = null; }; - // surfaceId identifies the store; a taskId change while the same + // surfaceId/scope identify the store; a scopeId change while the same // surface stays mounted is handled by the effect below via - // store.setTaskId(), not by recreating the store. + // store.setScopeId(), not by recreating the store. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [host, surfaceId]); + }, [host, surfaceId, scope]); React.useEffect(() => { - if (storeRef.current) storeRef.current.setTaskId(taskId); - }, [taskId]); + if (storeRef.current) storeRef.current.setScopeId(resolvedScopeId); + }, [resolvedScopeId]); return { snapshot, store: storeRef.current }; } @@ -960,10 +1040,11 @@ export function injectPluginStyles() { document.head.appendChild(style); } -function NotesEditor({ host, taskId, surfaceId, presentation }) { +function NotesEditor({ host, scope = DEFAULT_SCOPE, scopeId, taskId, surfaceId, presentation, onCloseModal }) { const { jsx: h, ui } = host; const React = host.React; - const { snapshot, store } = useNoteStore(host, { taskId, surfaceId }); + const resolvedScopeId = scopeId ?? taskId; + const { snapshot, store } = useNoteStore(host, { scope, scopeId: resolvedScopeId, surfaceId }); const textareaRef = React.useRef(null); const pendingSelectionRef = React.useRef(null); const [enhanceState, dispatchEnhance] = React.useReducer(enhancePreviewReducer, initialEnhanceState); @@ -1090,10 +1171,22 @@ function NotesEditor({ host, taskId, surfaceId, presentation }) { type: "failure", message: error && error.message ? error.message : "Could not enhance this note.", notConfigured: Boolean(error && error.notConfigured), + code: error && error.code, }), ); } + // handleEnhanceErrorAction (C4): navigates to the settings page + // enhanceErrorAction resolved for the current error's code. Closes this + // surface's own modal first when there is one (onCloseModal, threaded + // down from makeNoteModalContent/openScopedNoteModal) — navigating away + // while the modal is still open would leave it mounted over the + // destination page. + function handleEnhanceErrorAction(action) { + if (onCloseModal) onCloseModal(); + host.navigate(action.href); + } + function handleAcceptEnhance() { store.setValue(enhanceState.preview); dispatchEnhance({ type: "accept" }); @@ -1212,6 +1305,7 @@ function NotesEditor({ host, taskId, surfaceId, presentation }) { ) : null; + const enhanceErrorGuidedAction = enhanceState.status === "error" ? enhanceErrorAction(enhanceState.code) : null; const enhanceError = enhanceState.status === "error" ? h( @@ -1227,6 +1321,19 @@ function NotesEditor({ host, taskId, surfaceId, presentation }) { { type: "button", size: "sm", variant: "ghost", onClick: () => dispatchEnhance({ type: "dismiss" }) }, "Dismiss", ), + enhanceErrorGuidedAction + ? h( + ui.Button, + { + type: "button", + size: "sm", + variant: "ghost", + "data-testid": "notes-enhance-error-action", + onClick: () => handleEnhanceErrorAction(enhanceErrorGuidedAction), + }, + enhanceErrorGuidedAction.label, + ) + : null, ) : null; @@ -1268,8 +1375,8 @@ function NotesEditor({ host, taskId, surfaceId, presentation }) { ), useRichEditor ? h(ui.RichTextEditor, { - key: `${surfaceId}-${taskId}-${editorKey}`, - taskId, + key: `${surfaceId}-${resolvedScopeId}-${editorKey}`, + taskId: resolvedScopeId, value: snapshot.value, onChange: handleRichTextChange, placeholder: "Jot a note about this task… (Markdown supported)", @@ -1308,15 +1415,17 @@ function makeNotesPanelComponent(host) { }; } -function bookGlyph(h) { +function bookGlyph(h, size = 12) { // Inline SVG — this bundle ships no build step and cannot import an icon - // set. Matches the curated "book" icon used for the panel's own tab. + // set. Matches the curated "book" icon used for the panel's own tab. size + // defaults to the card indicator's 12px; the sidebar button passes 14 to + // match its siblings' `h-3.5 w-3.5` (RowActionButton) glyph size. return h( "svg", { xmlns: "http://www.w3.org/2000/svg", - width: 12, - height: 12, + width: size, + height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", @@ -1330,24 +1439,28 @@ function bookGlyph(h) { ); } -function useNoteIndicator(host, taskId) { +function useNoteIndicator(host, scopeId, scope = DEFAULT_SCOPE) { const React = host.React; - const [hasNote, setHasNote] = React.useState(() => noteCache.get(taskId) ?? false); + const [hasNote, setHasNote] = React.useState(() => noteCache.get(cacheKey(scope, scopeId)) ?? false); React.useEffect(() => { - if (!taskId) return undefined; + if (!scopeId) return undefined; let cancelled = false; - getCachedHasNote(host, taskId).then((value) => { - if (!cancelled) setHasNote(value); - }); - const unsubscribe = subscribeCache(taskId, (value) => { + getCachedHasNote(host, scopeId, scope).then((value) => { if (!cancelled) setHasNote(value); }); + const unsubscribe = subscribeCache( + scopeId, + (value) => { + if (!cancelled) setHasNote(value); + }, + scope, + ); return () => { cancelled = true; unsubscribe(); }; - }, [host, taskId]); + }, [host, scopeId, scope]); return hasNote; } @@ -1386,10 +1499,21 @@ function makeCardIndicatorComponent(host) { }; } -export function openNoteModal(host, taskId, taskTitle) { - host.openModal({ - title: taskTitle ? `Edit notes — ${taskTitle}` : "Edit notes", - content: makeNoteModalContent(host, taskId), +// openScopedNoteModal is the shared body behind openNoteModal (scope: +// "task") and openWorkspaceNoteModal (scope: "workspace") — same modal +// chrome, same fixed-height NotesEditor, differing only in scope/scopeId and +// title. The PluginModalHandle host.openModal returns is only available +// *after* the call, but makeNoteModalContent needs a close() callback to +// hand NotesEditor *before* that — closeRef bridges the gap: the content +// factory closes over closeRef and calls whatever's in it, which is filled +// in immediately after openModal returns, before React ever renders the +// modal body. +function openScopedNoteModal(host, { scope, scopeId, title }) { + const closeRef = {}; + const content = makeNoteModalContent(host, { scope, scopeId }, () => closeRef.close && closeRef.close()); + const handle = host.openModal({ + title, + content, // "xl" (sm:max-w-5xl) is the widest size PluginModalOptions offers — // closest match to a spacious note-editing surface. Height is fixed by // NotesEditor's own containerStyle for presentation "modal" (a fixed @@ -1397,6 +1521,66 @@ export function openNoteModal(host, taskId, taskTitle) { // grows as the note is typed; the textarea scrolls internally instead. size: "xl", }); + closeRef.close = handle.close; + return handle; +} + +export function openNoteModal(host, taskId, taskTitle) { + return openScopedNoteModal(host, { + scope: DEFAULT_SCOPE, + scopeId: taskId, + title: taskTitle ? `Edit notes — ${taskTitle}` : "Edit notes", + }); +} + +// openWorkspaceNoteModal is the sidebar button's entry point (B3) — same +// NotesEditor, same PluginModalHost, scope: "workspace" instead of "task". +export function openWorkspaceNoteModal(host, workspaceId, workspaceLabel) { + return openScopedNoteModal(host, { + scope: "workspace", + scopeId: workspaceId, + title: workspaceLabel ? `Workspace notes — ${workspaceLabel}` : "Workspace notes", + }); +} + +// resolveWorkspaceId prefers the sidebar slot's own slotProps.workspaceId +// (forwarded by the host from the same useAppStore read the New Task row +// itself performs — see app-sidebar-workspace-actions.tsx) and falls back to +// reading the app store directly, per B6, so the button still resolves an id +// on a host that predates that slotProps field. +function resolveWorkspaceId(slotProps, host) { + if (slotProps && slotProps.workspaceId) return slotProps.workspaceId; + const state = host.store && typeof host.store.getState === "function" ? host.store.getState() : null; + return (state && state.workspaces && state.workspaces.activeId) || null; +} + +// makeWorkspaceNotesButton — the sidebar-workspace-actions slot component +// (B3-B6). Pixel-identical to its RowActionButton siblings (Quick Terminal, +// Quick Chat): same 24px hit target, same hover classes, same 14px glyph +// size — text-muted-foreground/70 when the workspace has no note, +// text-foreground once it does (B5), flipping live via useNoteIndicator's +// cache subscription, including a write from another tab. +function makeWorkspaceNotesButton(host) { + return function WorkspaceNotesButton({ slotProps }) { + const workspaceId = resolveWorkspaceId(slotProps, host); + const workspaceLabel = slotProps && slotProps.workspaceLabel; + const hasNote = useNoteIndicator(host, workspaceId, "workspace"); + if (!workspaceId) return null; + return host.jsx( + "button", + { + type: "button", + "data-testid": "notes-workspace-sidebar-button", + title: "Workspace notes", + "aria-label": "Workspace notes", + className: `flex h-6 w-6 items-center justify-center rounded cursor-pointer hover:bg-muted hover:text-foreground ${ + hasNote ? "text-foreground" : "text-muted-foreground/70" + }`, + onClick: () => openWorkspaceNoteModal(host, workspaceId, workspaceLabel), + }, + bookGlyph(host.jsx, 14), + ); + }; } // --------------------------------------------------------------------------- @@ -1417,6 +1601,12 @@ window.registerKandevPlugin("kandev-plugin-notes", { registry.registerComponent("task-card-indicators", makeCardIndicatorComponent(host)); + // Workspace-scoped note button (B3-B7). Registering for a slot name the + // host doesn't (yet) mount is a documented no-op (PluginRegistry does no + // name validation; PluginSlot renders nothing for zero registrations), + // so this stays inert on a host build without the sidebar slot. + registry.registerComponent("sidebar-workspace-actions", makeWorkspaceNotesButton(host)); + registry.registerTaskMenuAction({ id: "edit-notes", label: "Edit notes", diff --git a/ui/bundle.test.mjs b/ui/bundle.test.mjs index f28b2fb..2e2c9e6 100644 --- a/ui/bundle.test.mjs +++ b/ui/bundle.test.mjs @@ -182,6 +182,7 @@ const { disposeNoteIndicatorSubscription, makeNoteModalContent, openNoteModal, + openWorkspaceNoteModal, applyBold, applyItalic, applyHeading, @@ -193,6 +194,7 @@ const { applyCodeBlock, enhanceNote, enhancePreviewReducer, + enhanceErrorAction, initialEnhanceState, injectPluginStyles, } = bundle; @@ -283,7 +285,7 @@ test("AC10: panel writes and subscribes with its own surfaceId (panelId)", async test("AC11: the kanban modal surface uses the bare id 'note-modal' as its writerId", () => { const { host, jsxCalls } = createFakeHost(); - const NoteModalContent = makeNoteModalContent(host, "task-42"); + const NoteModalContent = makeNoteModalContent(host, { scope: "task", scopeId: "task-42" }); NoteModalContent(); assert.equal(jsxCalls.length, 1); @@ -303,7 +305,7 @@ test("AC11: the kanban modal surface uses the bare id 'note-modal' as its writer // presentation alone. test("the kanban modal surface uses presentation 'modal', the same NotesEditor as the panel", () => { const { host, jsxCalls } = createFakeHost(); - const NoteModalContent = makeNoteModalContent(host, "task-42"); + const NoteModalContent = makeNoteModalContent(host, { scope: "task", scopeId: "task-42" }); NoteModalContent(); assert.equal(jsxCalls.length, 1); @@ -1147,6 +1149,68 @@ test("enhanceNote maps a 412 response to a distinguishable notConfigured error", ); }); +// --- C1/C5: enhanceNote surfaces the server's code/detail, or falls back -- + +test("C1: enhanceNote surfaces the server's code and detail from a 412 body", async () => { + const host = fakeApiHost(async () => + fakeJsonResponse(412, { + error: "The utility agent configured for this plugin is disabled — enable it (with a model) in Settings > Utility Agents.", + code: "agent_disabled", + detail: `configured utility agent "builtin-enhance-prompt" is disabled`, + }), + ); + + await assert.rejects( + () => enhanceNote(host, "raw markdown"), + (error) => { + assert.equal(error.code, "agent_disabled"); + assert.equal(error.detail, `configured utility agent "builtin-enhance-prompt" is disabled`); + return true; + }, + ); +}); + +test("C5: enhanceNote leaves code/detail undefined when an older server's 412 body omits them", async () => { + const host = fakeApiHost(async () => + fakeJsonResponse(412, { error: "no utility agent is configured for this plugin" }), + ); + + await assert.rejects( + () => enhanceNote(host, "raw markdown"), + (error) => { + assert.equal(error.code, undefined); + assert.equal(error.detail, undefined); + assert.equal(error.notConfigured, true, "the pre-existing notConfigured flag still works unmodified"); + return true; + }, + ); +}); + +// --- C2/C4/C5: enhanceErrorAction maps a code to its one correct remedy --- + +test("C2: enhanceErrorAction sends agent_unset and agent_missing to the Notes plugin settings page", () => { + assert.deepEqual(enhanceErrorAction("agent_unset"), { + label: "Choose an agent", + href: "/settings/plugins/kandev-plugin-notes", + }); + assert.deepEqual(enhanceErrorAction("agent_missing"), { + label: "Choose an agent", + href: "/settings/plugins/kandev-plugin-notes", + }); +}); + +test("C2: enhanceErrorAction sends agent_disabled to Utility Agents, a different page than agent_unset", () => { + const action = enhanceErrorAction("agent_disabled"); + assert.equal(action.href, "/settings/utility-agents"); + assert.notEqual(action.href, enhanceErrorAction("agent_unset").href); +}); + +test("C5: enhanceErrorAction returns null for agent_unavailable, an unrecognized code, and a missing code", () => { + assert.equal(enhanceErrorAction("agent_unavailable"), null); + assert.equal(enhanceErrorAction("something_new_the_server_added"), null); + assert.equal(enhanceErrorAction(undefined), null); +}); + test("enhanceNote surfaces other non-2xx statuses as a generic (non-notConfigured) error", async () => { const host = fakeApiHost(async () => fakeJsonResponse(502, { error: "AI enhancement failed" })); @@ -1221,3 +1285,190 @@ test("enhancePreviewReducer: failure carries the message and notConfigured flag; state = enhancePreviewReducer(state, { type: "dismiss" }); assert.equal(state.status, "idle"); }); + +test("C2: enhancePreviewReducer carries the failure's code through to state for the error branch to act on", () => { + let state = enhancePreviewReducer(initialEnhanceState, { type: "start" }); + state = enhancePreviewReducer(state, { + type: "failure", + message: "The utility agent configured for this plugin is disabled — enable it (with a model) in Settings > Utility Agents.", + notConfigured: true, + code: "agent_disabled", + }); + assert.equal(state.code, "agent_disabled"); +}); + +// --------------------------------------------------------------------------- +// Per-workspace notes (B1-B4): createNoteStore, the indicator cache, and the +// modal factory generalized from a hardcoded "task" scope to any +// (scope, scopeId) pair. Every test above this section exercises the +// default ("task") scope implicitly via its taskId option; this section +// exercises the "workspace" scope explicitly and the isolation between the +// two. +// --------------------------------------------------------------------------- + +test("B1: a workspace-scope store reads and writes user-state/workspace//note", async () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const { host, callsOf } = createFakeHost(); + const store = createNoteStore(host, { scope: "workspace", scopeId: "ws-1", surfaceId: "ws-sidebar" }); + + const getCall = callsOf("get")[0]; + assert.deepEqual(getCall.args, { scope: "workspace", scopeId: "ws-1", key: "note" }); + getCall.resolve({ value: "half-formed idea", updatedAt: "u1" }); + await flush(); + + const snapshot = store.getSnapshot(); + assert.equal(snapshot.scope, "workspace"); + assert.equal(snapshot.scopeId, "ws-1"); + assert.equal(snapshot.taskId, null, "taskId is only meaningful for scope \"task\""); + assert.equal(snapshot.value, "half-formed idea"); + + store.setValue("revised idea"); + mock.timers.tick(150); + await flush(); + assert.deepEqual(callsOf("set")[0].args, { + scope: "workspace", + scopeId: "ws-1", + key: "note", + value: "revised idea", + options: { writerId: "ws-sidebar", ifUnmodifiedSince: "u1" }, + }); + store.dispose(); + } finally { + mock.timers.reset(); + } +}); + +test("B1: the failed-read diagnostic probe carries the store's own scope in its path", async () => { + const { host, callsOf, apiCalls } = createFakeHost(); + const store = createNoteStore(host, { scope: "workspace", scopeId: "ws-2", surfaceId: "ws-sidebar" }); + callsOf("get")[0].reject(new Error("network error status 500")); + await flush(); + + assert.equal(apiCalls.length, 1); + assert.equal(apiCalls[0].path, "user-state/workspace/ws-2/note"); + store.dispose(); +}); + +test("B1: setScopeId (the setTaskId alias) clears value/updatedAt synchronously across a scope switch", async () => { + const { host, callsOf } = createFakeHost(); + const store = createNoteStore(host, { scope: "workspace", scopeId: "ws-A", surfaceId: "ws-sidebar" }); + const getForA = callsOf("get")[0]; + assert.equal(getForA.args.scopeId, "ws-A"); + getForA.resolve({ value: "note A", updatedAt: "uA" }); + await flush(); + assert.equal(store.getSnapshot().value, "note A"); + + store.setScopeId("ws-B"); + assert.equal(store.getSnapshot().value, "", "cleared synchronously, before ws-B's read resolves"); + assert.equal(store.getSnapshot().loaded, false); + assert.equal(store.getSnapshot().scopeId, "ws-B"); + assert.equal(store.getSnapshot().scope, "workspace", "scope itself is fixed for the store's lifetime"); + + callsOf("get")[1].resolve({ value: "note B", updatedAt: "uB" }); + await flush(); + assert.equal(store.getSnapshot().value, "note B"); + store.dispose(); +}); + +test("B1: setTaskId remains a working alias for setScopeId", async () => { + const { host, callsOf } = createFakeHost(); + const store = createNoteStore(host, { taskId: "task-A", surfaceId: "panel-1" }); + callsOf("get")[0].resolve(undefined); + await flush(); + + store.setTaskId("task-B"); + assert.equal(store.getSnapshot().scopeId, "task-B"); + assert.equal(store.getSnapshot().taskId, "task-B"); + store.dispose(); +}); + +test("B2: the indicator cache never conflates a task id with a workspace id of the same value", async () => { + disposeNoteIndicatorSubscription(); + const { host, callsOf } = createFakeHost(); + + const taskLookup = getCachedHasNote(host, "shared-id", "task"); + callsOf("get")[0].resolve({ value: "task note", updatedAt: "u1" }); + assert.equal(await taskLookup, true); + + const workspaceLookup = getCachedHasNote(host, "shared-id", "workspace"); + assert.equal(callsOf("get").length, 2, "a workspace lookup for the same raw id must not reuse the task's cache entry"); + callsOf("get")[1].resolve(undefined); + assert.equal(await workspaceLookup, false); + + // Re-reading task's entry still serves from cache — the workspace lookup + // above did not clobber it. + assert.equal(await getCachedHasNote(host, "shared-id", "task"), true); + assert.equal(callsOf("get").length, 2, "no further get for the already-cached task entry"); +}); + +test("B2: a workspace subscribe notification flips only the workspace cache entry, not a same-id task entry", async () => { + disposeNoteIndicatorSubscription(); + const fake = createFakeHost(); + initNoteIndicatorSubscription(fake.host); + + markNote("shared-id-2", true, "task"); + const workspacePrimed = getCachedHasNote(fake.host, "shared-id-2", "workspace"); + fake.callsOf("get")[0].resolve(undefined); + assert.equal(await workspacePrimed, false); + + fake.emit({ scope: "workspace", scopeId: "shared-id-2", key: "note", updatedAt: "u2", deleted: false }); + await flush(); + + assert.equal(await getCachedHasNote(fake.host, "shared-id-2", "workspace"), true); + assert.equal(await getCachedHasNote(fake.host, "shared-id-2", "task"), true, "the task entry is untouched by the workspace notification"); + disposeNoteIndicatorSubscription(); +}); + +test("B2: initNoteIndicatorSubscription's own subscribe filter carries no scope, so it sees every scope's changes", () => { + const { host, subscribers } = createFakeHost(); + initNoteIndicatorSubscription(host); + assert.equal(subscribers.length, 1); + assert.equal(subscribers[0].filter.scope, undefined); + assert.equal(subscribers[0].filter.key, "note"); + disposeNoteIndicatorSubscription(); +}); + +test("openWorkspaceNoteModal opens a modal titled with the workspace label, scope: workspace", () => { + const { host, openModalCalls, jsxCalls } = createFakeHost(); + + openWorkspaceNoteModal(host, "ws-1"); + assert.equal(openModalCalls[0].title, "Workspace notes"); + assert.equal(openModalCalls[0].size, "xl"); + + openWorkspaceNoteModal(host, "ws-2", "Marketing site"); + assert.equal(openModalCalls[1].title, "Workspace notes — Marketing site"); + + openModalCalls[0].content(); + openModalCalls[1].content(); + assert.equal(jsxCalls[0].props.scope, "workspace"); + assert.equal(jsxCalls[0].props.scopeId, "ws-1"); + assert.equal(jsxCalls[1].props.scopeId, "ws-2"); +}); + +test("a modal's content factory can close its own modal via the PluginModalHandle openNoteModal/openWorkspaceNoteModal returned", () => { + const { host, jsxCalls } = createFakeHost(); + let closed = 0; + let capturedContent; + host.openModal = (options) => { + capturedContent = options.content; + return { + close: () => { + closed += 1; + }, + }; + }; + + openWorkspaceNoteModal(host, "ws-1"); + capturedContent(); + assert.equal(typeof jsxCalls[0].props.onCloseModal, "function"); + jsxCalls[0].props.onCloseModal(); + assert.equal(closed, 1); +}); + +test("initialize() registers a sidebar-workspace-actions component (B4/B7 — inert on a host without the slot)", () => { + const { host } = createFakeHost(); + const registry = createFakeRegistry(); + registeredPlugin.initialize(registry, host); + assert.ok(registry.registerComponentCalls.some((c) => c.slot === "sidebar-workspace-actions")); +}); From 3cc89fb859e797244d91a491cbc8b9ca82d632fb Mon Sep 17 00:00:00 2001 From: ayattara Date: Tue, 11 Aug 2026 14:21:20 +0000 Subject: [PATCH 02/14] feat: diagnose Enhance with AI failures and add guided setup The enhance webhook's 412 body now carries a stable, machine-readable code (agent_unset/agent_missing/agent_disabled/agent_unavailable), classified from the host's real FailedPrecondition message, plus the raw message as detail. The UI maps each code to its own guided-setup action button (Settings > Plugins > Notes for unset/missing, Settings > Utility Agents for disabled) instead of one message pointing everyone at the same page regardless of which setting is actually missing. An older server that omits code still gets today's plain-message behavior. --- server/plugin.go | 85 +++++++++++++++++++++++++++++++++++++++--- server/plugin_test.go | 87 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 6 deletions(-) diff --git a/server/plugin.go b/server/plugin.go index d7a1ea1..1c9761c 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -16,6 +16,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "github.com/kandev/kandev/pkg/pluginsdk" "google.golang.org/grpc/codes" @@ -49,11 +50,74 @@ type enhanceResponseBody struct { Content string `json:"content"` } +// enhanceErrorCode is the enhance webhook's stable, machine-readable 412 +// classification (C1) — the UI maps it to a specific settings page (C2) +// instead of guessing from prose. Every other failure branch (400/404/405/ +// 502/503) omits Code/Detail and keeps the plain {error} shape it always +// had, via omitempty below. +type enhanceErrorCode string + +const ( + // enhanceErrorCodeAgentUnset: the plugin has no utility agent selected + // at all (Settings > Plugins > Notes was never used). + enhanceErrorCodeAgentUnset enhanceErrorCode = "agent_unset" + // enhanceErrorCodeAgentMissing: the selected agent id no longer exists + // (deleted after selection). + enhanceErrorCodeAgentMissing enhanceErrorCode = "agent_missing" + // enhanceErrorCodeAgentDisabled: the selected agent exists but is + // disabled — a different fix (Settings > Utility Agents), not a + // reselection, per the ADR 0048 Enabled asymmetry this plugin cannot + // change (see README's setup section). + enhanceErrorCodeAgentDisabled enhanceErrorCode = "agent_disabled" + // enhanceErrorCodeAgentUnavailable: a FailedPrecondition whose wording + // matched none of the above — the host may have rephrased its message. + // Detail still carries that raw wording verbatim so the user sees real + // information rather than a guessed instruction. + enhanceErrorCodeAgentUnavailable enhanceErrorCode = "agent_unavailable" +) + +// enhanceErrorMessages pairs each code with the one correct remedy. Keep +// this the single source of truth for that mapping — HandleWebhook never +// composes an error message inline, so unset/missing and disabled can never +// be swapped at a call site. +var enhanceErrorMessages = map[enhanceErrorCode]string{ + enhanceErrorCodeAgentUnset: "No utility agent is configured for this plugin — configure one in Settings > Plugins > Notes.", + enhanceErrorCodeAgentMissing: "The utility agent configured for this plugin no longer exists — choose another one in Settings > Plugins > Notes.", + enhanceErrorCodeAgentDisabled: "The utility agent configured for this plugin is disabled — enable it (with a model) in Settings > Utility Agents.", + enhanceErrorCodeAgentUnavailable: "The configured utility agent is unavailable — check Settings > Plugins > Notes.", +} + +// classifyUtilityAgentError maps host_utility.go's three distinguishable +// FailedPrecondition wordings ("no utility agent configured for this +// plugin", "configured utility agent %q not found", "configured utility +// agent %q is disabled") to a stable code, kept as its own function (rather +// than inlined at the call site) so the mapping is unit-testable in +// isolation and has exactly one home. Substring matching is coupled to the +// host's current wording — a rephrase degrades to +// enhanceErrorCodeAgentUnavailable rather than misclassifying, since Detail +// (the raw message) is always preserved alongside it. +func classifyUtilityAgentError(message string) enhanceErrorCode { + switch { + case strings.Contains(message, "no utility agent configured"): + return enhanceErrorCodeAgentUnset + case strings.Contains(message, "not found"): + return enhanceErrorCodeAgentMissing + case strings.Contains(message, "is disabled"): + return enhanceErrorCodeAgentDisabled + default: + return enhanceErrorCodeAgentUnavailable + } +} + // enhanceErrorBody is the JSON body returned on a handled failure (missing // utility agent, bad input) — a stable {error} shape the UI can surface -// without parsing prose out of a plain-text body. +// without parsing prose out of a plain-text body. Code/Detail are only ever +// populated on the 412 (utility-agent) branch; every other branch keeps +// the bare {error} shape it always had. type enhanceErrorBody struct { - Error string `json:"error"` + Error string `json:"error"` + Code enhanceErrorCode `json:"code,omitempty"` + Detail string `json:"detail,omitempty"` } // notesPlugin implements pluginsdk.Plugin via UnimplementedPlugin's no-op @@ -94,10 +158,19 @@ func (p *notesPlugin) HandleWebhook(ctx context.Context, req *pluginsdk.WebhookR improved, err := host.InvokeUtilityAgent(ctx, fmt.Sprintf(enhancePromptTemplate, body.Content)) if err != nil { if status.Code(err) == codes.FailedPrecondition { - // No utility agent configured (or the configured one was - // deleted/disabled) — a distinguishable, non-fatal condition - // per ADR 0048, not an internal error. - return jsonErrorResponse(http.StatusPreconditionFailed, "no utility agent is configured for this plugin — configure one in Settings > Plugins > Notes") + // No utility agent configured, or the configured one was + // deleted/disabled — a distinguishable, non-fatal condition per + // ADR 0048, not an internal error. classifyUtilityAgentError + // turns the host's raw gRPC message into a stable code (C1) so + // the UI can point at the correct settings page (C2) instead of + // this one message covering unset/missing/disabled alike. + rawMessage := status.Convert(err).Message() + code := classifyUtilityAgentError(rawMessage) + return jsonResponse(http.StatusPreconditionFailed, enhanceErrorBody{ + Error: enhanceErrorMessages[code], + Code: code, + Detail: rawMessage, + }) } return jsonErrorResponse(http.StatusBadGateway, "AI enhancement failed") } diff --git a/server/plugin_test.go b/server/plugin_test.go index a87876b..5209734 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -190,6 +190,8 @@ func TestHandleWebhook_Enhance_NoUtilityAgentConfigured_ReturnsPreconditionFaile var out enhanceErrorBody require.NoError(t, json.Unmarshal(resp.Body, &out)) require.NotEmpty(t, out.Error) + require.Equal(t, enhanceErrorCodeAgentUnset, out.Code) + require.Equal(t, "no utility agent configured for this plugin", out.Detail) } func TestHandleWebhook_Enhance_OtherAgentError_ReturnsBadGateway(t *testing.T) { @@ -208,4 +210,89 @@ func TestHandleWebhook_Enhance_OtherAgentError_ReturnsBadGateway(t *testing.T) { }) require.NoError(t, err) require.Equal(t, int32(502), resp.Status) + + // C3: a real execution failure must never turn into a configuration + // message — no code/detail leak onto this generic branch. + var out enhanceErrorBody + require.NoError(t, json.Unmarshal(resp.Body, &out)) + require.Empty(t, out.Code) + require.Empty(t, out.Detail) + require.Equal(t, "AI enhancement failed", out.Error) +} + +// TestHandleWebhook_Enhance_ClassifiesEachFailedPreconditionWording is C1/C7: +// each of host_utility.go's three distinguishable wordings, plus an +// unrecognized one, maps to its own code with the raw message preserved +// verbatim as Detail. +func TestHandleWebhook_Enhance_ClassifiesEachFailedPreconditionWording(t *testing.T) { + tests := []struct { + name string + hostMessage string + wantCode enhanceErrorCode + }{ + { + name: "unset", + hostMessage: "no utility agent configured for this plugin", + wantCode: enhanceErrorCodeAgentUnset, + }, + { + name: "missing", + hostMessage: `configured utility agent "builtin-enhance-prompt" not found`, + wantCode: enhanceErrorCodeAgentMissing, + }, + { + name: "disabled", + hostMessage: `configured utility agent "builtin-enhance-prompt" is disabled`, + wantCode: enhanceErrorCodeAgentDisabled, + }, + { + name: "unrecognized wording degrades to unavailable, not a wrong instruction", + hostMessage: "utility agent invocation is temporarily throttled", + wantCode: enhanceErrorCodeAgentUnavailable, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := ¬esPlugin{} + p.SetHost(&fakeHost{invokeUtilityAgent: func(context.Context, string) (string, error) { + return "", status.Error(codes.FailedPrecondition, tt.hostMessage) + }}) + + body, err := json.Marshal(map[string]string{"content": "hello"}) + require.NoError(t, err) + + resp, err := p.HandleWebhook(context.Background(), &pluginsdk.WebhookRequest{ + WebhookKey: "enhance", + Method: "POST", + Body: body, + }) + require.NoError(t, err) + require.Equal(t, int32(412), resp.Status) + + var out enhanceErrorBody + require.NoError(t, json.Unmarshal(resp.Body, &out)) + require.Equal(t, tt.wantCode, out.Code) + require.Equal(t, tt.hostMessage, out.Detail) + require.Equal(t, enhanceErrorMessages[tt.wantCode], out.Error) + }) + } +} + +// TestClassifyUtilityAgentError_TableDriven exercises classifyUtilityAgentError +// directly, isolated from HandleWebhook and the gRPC status plumbing. +func TestClassifyUtilityAgentError_TableDriven(t *testing.T) { + tests := []struct { + message string + want enhanceErrorCode + }{ + {"no utility agent configured for this plugin", enhanceErrorCodeAgentUnset}, + {`configured utility agent "x" not found`, enhanceErrorCodeAgentMissing}, + {`configured utility agent "x" is disabled`, enhanceErrorCodeAgentDisabled}, + {"", enhanceErrorCodeAgentUnavailable}, + {"something else entirely", enhanceErrorCodeAgentUnavailable}, + } + for _, tt := range tests { + require.Equal(t, tt.want, classifyUtilityAgentError(tt.message), "message: %q", tt.message) + } } From 49b5385d2c06d47b51c027ed6dfcbc32a5fc68c0 Mon Sep 17 00:00:00 2001 From: ayattara Date: Tue, 11 Aug 2026 14:21:24 +0000 Subject: [PATCH 03/14] chore: bump version to 0.3.0, document workspace notes and AI setup README documents the new sidebar workspace-notes button and the two-step Enhance with AI setup (select an agent, then enable it with a model under Settings > Utility Agents), including the Enabled asymmetry between kandev's own prompt enhancement and plugin utility-agent calls. --- CHANGELOG.md | 17 ++++++++++++++ Makefile | 2 +- README.md | 65 +++++++++++++++++++++++++++++++++++++++++---------- manifest.yaml | 4 ++-- 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9943278..41e523f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.3.0] - 2026-08-11 + +### Added + +- feat: per-workspace notes — a sidebar button (registered for the host's + `sidebar-workspace-actions` slot, inert on hosts without it) opens the same + note editor/modal scoped to the active workspace instead of a task +- feat: Enhance with AI now returns a stable, machine-readable failure code + (unset/missing/disabled/unavailable) and a guided-setup action button that + jumps to the correct settings page instead of one message for every cause + +### Changed + +- `createNoteStore` and the card-indicator cache are now scope-generic + (`scope`/`scopeId` instead of a hardcoded "task"); existing task callers are + unaffected (`taskId` remains a working alias) + ## [0.2.3] - 2026-08-11 ### Changed diff --git a/Makefile b/Makefile index f6e2b23..4ec178b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build run test fmt vet package package-host clean BIN := bin/kandev-plugin-notes -VERSION := 0.2.3 +VERSION := 0.3.0 STAGE := .build/stage PKG_OUT := kandev-plugin-notes-$(VERSION).tar.gz diff --git a/README.md b/README.md index 72967d5..16f1759 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,16 @@ editor and optional AI-assisted proofreading. overwritten automatically. See "Notes are private to you" below for the privacy trade-off this makes. - **Card indicator** — a small glyph on cards that have a note. +- **Workspace notes** — a small book icon beside Quick Terminal and Quick Chat + in the sidebar's New Task row (on a host build that carries the + `sidebar-workspace-actions` slot) opens the same editor, modal, toolbar, + Preview, and Enhance with AI included, scoped to the active **workspace** + instead of a task. Use it for a half-formed idea or a reminder that isn't + worth creating a task to hold. The icon is muted when the workspace has no + note and full-contrast once it does, and flips live (no reload) as the note + is written, emptied, or edited from another tab. A task's note and its + workspace's note are stored and shown independently; the icon is not + rendered without an active workspace. - **Cross-tab sync** — an edit in one tab shows up in another without a reload. ## If a note won't load @@ -81,24 +91,57 @@ otherwise save over an existing note the read never actually saw. ## Notes are private to you — except when you ask AI to enhance one -Each note is stored per **user**, per **task**, under the plugin's own key -(`("task", , "note")`) via Kandev's per-user plugin storage -(`capabilities.user_state`). Two people looking at the same task each see their -own note; nobody else can read yours, and the agent working the task cannot +Each note is stored per **user**, per **task or workspace** (whichever you +opened), under the plugin's own key (`(scope, id, "note")`, `scope` being +`"task"` or `"workspace"`) via Kandev's per-user plugin storage +(`capabilities.user_state`). Two people looking at the same task or workspace +each see their own note; nobody else can read yours, and no task's agent can read or write it. **The one exception is the "Enhance with AI" button.** Clicking it sends the note's current markdown to the utility agent configured for this plugin (**Settings > Plugins > Notes**) via a one-shot completion (`capabilities.agent_invoke` / `Host.InvokeUtilityAgent`) — that content -leaves the "nobody else can read it" boundary for that one request. If no -utility agent is configured, the button shows a clear, non-fatal message -instead of failing silently. Skip the button entirely to keep a note fully -private. +leaves the "nobody else can read it" boundary for that one request. See +"Setting up Enhance with AI" below for what has to be configured first, and +what each failure message means. Skip the button entirely to keep a note +fully private. -If you want the task's own agent to see something, put it in the task +If you want a task's own agent to see something, put it in the task description or say it in chat. This is a scratchpad, not a shared field. +## Setting up Enhance with AI + +"Enhance with AI" needs **two separate settings**, both satisfied, before it +can run: + +1. **Select an agent for this plugin** — Settings > Plugins > Notes, + `config_schema.utility_agent`. This is what tells the plugin which + utility agent to ask. +2. **Enable that agent, with a model** — Settings > Utility Agents. Selecting + an agent in step 1 does not enable it; a newly-added utility agent starts + disabled with no model chosen. + +Both steps are required because **a disabled utility agent is usable by +kandev's own built-in features (e.g. task-create prompt enhancement) but not +by any plugin**, including this one. Kandev's own prompt-enhancement path +does not check `Enabled`; this plugin's request goes through +`Host.InvokeUtilityAgent`, which does. That asymmetry is host behavior this +plugin cannot change — clicking Enhance with an agent selected-but-disabled +fails exactly like having no agent selected at all, and the two failures now +say so explicitly rather than both pointing back at Settings > Plugins > Notes: + +| Situation | Message points you to | +| --- | --- | +| No agent ever selected | Settings > Plugins > Notes | +| Selected agent was since deleted | Settings > Plugins > Notes | +| Selected agent exists but is disabled | **Settings > Utility Agents** | +| Anything else (a real execution failure) | no settings link — try again | + +Each error's **Dismiss** button is joined by a second action button that +jumps straight to the right page for that specific cause, so there's no need +to guess which of the two settings is missing. + ## Install Until the first release is published, install by sideload — build a package and @@ -113,9 +156,7 @@ curl -F "package=@kandev-plugin-notes-.tar.gz" \ Sideloaded plugins register disabled/unverified; enable it in **Settings > Plugins**. Reinstalling the same version returns 409 — bump the version in `manifest.yaml` (and `Makefile`) first. To use "Enhance with AI", -also pick a utility agent for this plugin under **Settings > Plugins > Notes** -(`config_schema.utility_agent`) — without one, the button surfaces a -not-configured message rather than failing. +see "Setting up Enhance with AI" above — it's a two-step setup, not one. ## Development diff --git a/manifest.yaml b/manifest.yaml index 7c92934..8207806 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -5,9 +5,9 @@ # (/api/plugins//...). Keep the three in sync when bumping version. id: "kandev-plugin-notes" api_version: 1 -version: "0.2.3" +version: "0.3.0" display_name: "Notes" -description: "A private, per-user scratchpad note on any task: a dockview/mobile panel, markdown editing with a formatting toolbar, AI-assisted proofreading, and a kanban card shortcut." +description: "A private, per-user scratchpad note on any task or workspace: a dockview/mobile panel, a sidebar workspace-notes button, markdown editing with a formatting toolbar, AI-assisted proofreading with guided setup, and a kanban card shortcut." author: "yattdev" categories: ["tools"] repo_url: "https://github.com/yattdev/kandev-plugin-notes" From 06e731d7a78f0aa9e73f67347e3f50affc928427 Mon Sep 17 00:00:00 2001 From: ayattara Date: Tue, 11 Aug 2026 22:47:39 +0000 Subject: [PATCH 04/14] qa: stop agent_unavailable naming a settings page it cannot know agent_unavailable is by definition a FailedPrecondition the plugin could not classify, but its message named Settings > Plugins > Notes anyway. That is reachable today, not hypothetically: host_utility.go has a fourth wording, "configured utility agent %q has no usable agent profile", fired when an agent is selected and enabled but its model/profile is still unbound - the state every built-in utility agent ships in. Following the README's own two-step setup lands there, and the message sent the user back to the page they had already used correctly, with no action button. The message now quotes the host's own wording instead of guessing a remedy, which is the degradation the classifier was designed for: a missing button, not a wrong instruction. Routing that case to its own code and action button is left to the author - C1 fixes the code set at four, so adding a fifth is a scope decision, not a QA fix. Also fixes the workspace note editor showing "Jot a note about this task..." under a modal titled "Workspace notes -