From 91708b555c71e8c8af355d927c89dff50eba82b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:09:20 +0000 Subject: [PATCH 1/8] feat: add a Feedback panel that hands canvas notes to a coding agent Notes pin to the current canvas selection and live in one file, .stacki/feedback.json, inside the open project (git-excluded with the rest of .stacki/). Claude Code reads that file with its own Read tool, edits the project, and flips each item's "done"; Stacki re-reads it on window focus, when the tab opens, and before every write. No server, port or watcher: the file is the interface, because the agent can already read the project. - shared/ipc/feedback.ts: the item and file parsers (version 1, bounded, ids unique). Agent-appended items may omit createdAt and use keys: []. - feedback:read / feedback:write: one channel pair through the usual six touch points. Main resolves each item's file:line trail from its selection keys with selectionTrail on every read and write, and writes through writeProjectText so the write is atomic like every project write. - Every renderer change reads the file fresh, applies itself, then writes, so an agent's done flips made in the meantime are not overwritten. - Feedback rail tab (M), with Copy for agent rendering the handoff text. - Numbered pins on the canvas for open notes at the current drill-in level; their paths join the frame's tracked set so it measures them. shortcut: pins are index paths, not handle-carried spans; a structural edit above a pinned node moves the pin to its neighbour. Upgrade by storing a byte span mapped with shared/engine/mapSpan.ts if notes start living across editing sessions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DAFvSVVtdvBZHDz2thvb12 --- electron/handlers/feedbackHandlers.ts | 61 ++++++++ electron/main.ts | 2 + electron/preload/preloadBridge.ts | 3 + shared/ipc/feedback.ts | 47 ++++++ shared/ipc/ipcPayloads.ts | 3 + shared/ipc/ipcResults.ts | 3 + shared/ipc/preloadApi.ts | 2 + src/app/LeftRail.tsx | 6 +- src/app/appPanels.ts | 1 + src/app/appTypes.ts | 1 + src/app/shell/AppPanes.tsx | 2 + src/app/shell/AppShell.tsx | 20 ++- src/app/state/appComposers.ts | 10 ++ src/features/feedback/FeedbackPanel.tsx | 125 ++++++++++++++++ src/features/feedback/feedback.css | 75 ++++++++++ src/features/feedback/feedbackBridge.ts | 55 +++++++ src/features/feedback/feedbackState.ts | 141 ++++++++++++++++++ src/features/preview/PreviewOverlays.tsx | 32 ++++ src/features/preview/PreviewPane.tsx | 73 +++++---- src/features/preview/previewRuntime.ts | 29 +++- src/features/preview/previewViewport.css | 18 +++ studio/scripts/shared-source.json | 1 + test/electron/app/mainBoundaries.test.ts | 5 +- .../handlers/feedbackHandlers.test.ts | 51 +++++++ test/renderer/feedback/feedback.test.js | 67 +++++++++ test/renderer/preview/pinOverlays.test.js | 50 +++++++ test/shared/ipc/feedback.test.ts | 28 ++++ test/shared/ipc/ipcPayloads.test.ts | 28 ++++ 28 files changed, 906 insertions(+), 33 deletions(-) create mode 100644 electron/handlers/feedbackHandlers.ts create mode 100644 shared/ipc/feedback.ts create mode 100644 src/features/feedback/FeedbackPanel.tsx create mode 100644 src/features/feedback/feedback.css create mode 100644 src/features/feedback/feedbackBridge.ts create mode 100644 src/features/feedback/feedbackState.ts create mode 100644 test/electron/handlers/feedbackHandlers.test.ts create mode 100644 test/renderer/feedback/feedback.test.js create mode 100644 test/renderer/preview/pinOverlays.test.js create mode 100644 test/shared/ipc/feedback.test.ts diff --git a/electron/handlers/feedbackHandlers.ts b/electron/handlers/feedbackHandlers.ts new file mode 100644 index 00000000..24d6fc65 --- /dev/null +++ b/electron/handlers/feedbackHandlers.ts @@ -0,0 +1,61 @@ +// The Feedback panel's IPC: reading and writing `.stacki/feedback.json`, each +// item's `file:line` trail resolved fresh from disk on the way in and out, so +// the agent that reads the file lands on the lines as they are now. + +import * as fs from 'fs'; +import * as path from 'path'; +import { assert } from '../../shared/core/assert'; +import { + FEEDBACK_FILE_VERSION, + parseFeedbackFile, + type FeedbackItem, + type TrailedFeedbackItem, +} from '../../shared/ipc/feedback'; +import { writeProjectText } from '../documents/documentWrites'; +import { selectionTrail } from '../documents/pageRequests'; +import { readJson } from '../lib/mainHelpers'; +import { ensureExcluded } from '../preview/previewWorktree'; +import type { MainHost } from './mainHost'; + +const FEEDBACK_FILE = path.join('.stacki', 'feedback.json'); + +export function registerFeedbackHandlers({ + ipcMain, + assertInProject, +}: Pick): void { + ipcMain.handle('feedback:read', (_event, { projectPath }) => { + const file = assertInProject(path.join(projectPath, FEEDBACK_FILE)); + if (!fs.existsSync(file)) { + return { items: [] }; + } + // A malformed file throws: the renderer shows why rather than emptying it. + return { items: withTrails(projectPath, parseFeedbackFile(readJson(file)).items) }; + }); + ipcMain.handle('feedback:write', (_event, { projectPath, items }) => { + const file = assertInProject(path.join(projectPath, FEEDBACK_FILE)); + fs.mkdirSync(path.dirname(file), { recursive: true }); + ensureExcluded(projectPath); + const trailed = withTrails(projectPath, items); + const text = JSON.stringify({ version: FEEDBACK_FILE_VERSION, items: trailed }, undefined, 2); + writeProjectText(file, text + '\n'); + return { items: trailed }; + }); +} + +// A key whose node is gone resolves to its file alone; one outside the +// project resolves to nothing, so its trail is empty. +export function withTrails( + projectPath: string, + items: readonly FeedbackItem[], +): readonly TrailedFeedbackItem[] { + const trailed = items.map((item) => ({ + ...item, + trail: selectionTrail({ projectPath, keys: [...item.keys] }) ?? [], + })); + assert(trailed.length === items.length, 'Every feedback item keeps its place'); + assert( + trailed.every((item) => item.trail.length <= item.keys.length), + 'A trail has at most one entry per key', + ); + return trailed; +} diff --git a/electron/main.ts b/electron/main.ts index 5b068ac0..5352535d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -61,6 +61,7 @@ import { Thumbnails, registerThumbnailHandlers } from './handlers/thumbnails'; import { registerAssetHandlers } from './handlers/assetHandlers'; import { registerCmsHandlers } from './handlers/cmsHandlers'; import { registerComponentHandlers } from './handlers/componentHandlers'; +import { registerFeedbackHandlers } from './handlers/feedbackHandlers'; import { registerContentHandlers } from './handlers/contentHandlers'; import { registerCssVariableHandlers } from './handlers/cssVariableHandlers'; import { registerGitHandlers } from './handlers/gitHandlers'; @@ -903,6 +904,7 @@ const host: MainHost = { registerAssetHandlers(host); registerCmsHandlers(host); registerComponentHandlers(host); +registerFeedbackHandlers(host); registerContentHandlers(host); registerCssVariableHandlers(host); registerGitHandlers(host); diff --git a/electron/preload/preloadBridge.ts b/electron/preload/preloadBridge.ts index 207c9b7e..d9e04b0a 100644 --- a/electron/preload/preloadBridge.ts +++ b/electron/preload/preloadBridge.ts @@ -92,6 +92,9 @@ function projectBridge() { // ⇧⌘C — the canvas selection as file:line pointers, for an AI chat. copySelection: invoke('selection:copy'), + // The Feedback panel's .stacki/feedback.json, trails resolved by main. + readFeedback: invoke('feedback:read'), + writeFeedback: invoke('feedback:write'), } satisfies Partial; } diff --git a/shared/ipc/feedback.ts b/shared/ipc/feedback.ts new file mode 100644 index 00000000..aa5412c0 --- /dev/null +++ b/shared/ipc/feedback.ts @@ -0,0 +1,47 @@ +// The Feedback panel's file, `.stacki/feedback.json` in the open project: notes +// pinned to canvas selections, read and edited by Stacki and by a coding agent +// alike. The file is the whole interface; nothing listens on a port. +import { assert } from '../core/assert'; +import { boolean, list, object, optional, record, text } from '../core/boundary'; +import type { Parsed } from '../core/boundary'; + +export const FEEDBACK_FILE_VERSION = 1; + +// `keys` are selection keys (`#`: the page, each drilled-in +// host, then the leaf). An agent may append its own items with `keys: []`, so +// `createdAt` is optional for them; the trail is main's to resolve, never read. +export const parseFeedbackItem = object({ + id: text, + keys: list(text), + label: text, + note: text, + createdAt: optional(text), + done: boolean, +}); +export type FeedbackItem = Parsed; + +/** An item with the `file:line` trail main resolved from its keys. */ +export type TrailedFeedbackItem = FeedbackItem & { readonly trail: readonly string[] }; + +export interface FeedbackFile { + readonly version: typeof FEEDBACK_FILE_VERSION; + readonly items: readonly FeedbackItem[]; +} + +export function parseFeedbackFile(input: unknown): FeedbackFile { + const value = record(input); + if (value['version'] !== FEEDBACK_FILE_VERSION) { + throw new Error(`Feedback file: expected version ${FEEDBACK_FILE_VERSION}`); + } + const items = list(parseFeedbackItem)(value['items']); + // Ids key the list and the pins; two items sharing one would edit each other. + if (new Set(items.map((item) => item.id)).size !== items.length) { + throw new Error('Feedback file: duplicate item id'); + } + assert(Array.isArray(items), 'Feedback items parse to an array'); + assert( + items.every((item) => typeof item.done === 'boolean'), + 'Every feedback item says whether it is done', + ); + return { version: FEEDBACK_FILE_VERSION, items }; +} diff --git a/shared/ipc/ipcPayloads.ts b/shared/ipc/ipcPayloads.ts index 331a6eb4..ee26a5d9 100644 --- a/shared/ipc/ipcPayloads.ts +++ b/shared/ipc/ipcPayloads.ts @@ -12,6 +12,7 @@ import { parseStudioRequestId } from './studioProgress'; import { parsePropertyChange, propertySource } from '../properties/propertyEditing'; import { parseEdit, parseEditRequest } from '../engine/editRequest'; import { parsePreviewRender } from '../page/previewToken'; +import { parseFeedbackItem } from './feedback'; // Complete invoke-channel inventory. Every main-process listener receives the // parsed shape below; payload field names retain the existing renderer protocol. import { @@ -179,6 +180,8 @@ export const IPC_PAYLOADS = { 'dev:probe': pathText, 'dev:start': pathText, 'dev:stop': nothing, + 'feedback:read': object({ ...project }), + 'feedback:write': object({ ...project, items: list(parseFeedbackItem) }), 'git:allFiles': object({ ...project }), 'git:checkout': object({ ...branch, create: maybeFlag, parkFirst: maybeFlag }), 'git:commit': object({ ...project, message: text, paths: optional(list(pathText)) }), diff --git a/shared/ipc/ipcResults.ts b/shared/ipc/ipcResults.ts index fd97ebb8..0cc9c435 100644 --- a/shared/ipc/ipcResults.ts +++ b/shared/ipc/ipcResults.ts @@ -14,6 +14,7 @@ import type { ComponentProperties } from '../properties/propertyEditing'; import type { PreviewVerdict } from '../page/previewToken'; import type { Result } from '../core/result'; import type { Data } from '../core/boundary'; +import type { TrailedFeedbackItem } from './feedback'; import { type WireAssetEntry, type WireCmsFile, @@ -357,6 +358,8 @@ export interface IpcResults { readonly 'dev:stop': { readonly ok: true; }; + readonly 'feedback:read': { readonly items: readonly TrailedFeedbackItem[] }; + readonly 'feedback:write': { readonly items: readonly TrailedFeedbackItem[] }; readonly 'git:allFiles': ReadonlyArray< WireProjectFile & WireFileDescription & { diff --git a/shared/ipc/preloadApi.ts b/shared/ipc/preloadApi.ts index ec88275a..4b149327 100644 --- a/shared/ipc/preloadApi.ts +++ b/shared/ipc/preloadApi.ts @@ -36,6 +36,8 @@ interface InvokeChannels { readonly readSourceText: 'src:readText'; readonly writeSourceText: 'src:writeText'; readonly copySelection: 'selection:copy'; + readonly readFeedback: 'feedback:read'; + readonly writeFeedback: 'feedback:write'; readonly listCms: 'cms:list'; readonly readCms: 'cms:read'; readonly writeCms: 'cms:write'; diff --git a/src/app/LeftRail.tsx b/src/app/LeftRail.tsx index 7a389500..75890437 100644 --- a/src/app/LeftRail.tsx +++ b/src/app/LeftRail.tsx @@ -10,6 +10,7 @@ import { VariableIcon, CodeIcon, HistoryIcon, + CommentIcon, } from '../ui/Icons'; const TABS = [ @@ -22,6 +23,7 @@ const TABS = [ { id: 'variables', title: 'Variables', shortcut: '⌥V', Icon: VariableIcon }, { id: 'code', title: 'Code', shortcut: 'C', Icon: CodeIcon }, { id: 'history', title: 'Git', shortcut: '⌥H', Icon: HistoryIcon }, + { id: 'feedback', title: 'Feedback', shortcut: 'M', Icon: CommentIcon }, ] as const; export type RailTab = (typeof TABS)[number]['id']; @@ -101,7 +103,7 @@ function useRailKeys( { componentOpen }: { readonly componentOpen: boolean }, onSelect: (tab: RailTab) => void, ): void { - // P / Z / ⇧A / J / ⌥C / ⌥H toggle the panels (ignored while typing in a field). + // P / Z / ⇧A / J / M / ⌥C / ⌥H toggle the panels (ignored while typing in a field). useEffect(() => { const onKey = (event: KeyboardEvent): void => { if (event.metaKey || event.ctrlKey) { @@ -143,6 +145,8 @@ function useRailKeys( id = 'assets'; } else if (key === 'c' && !event.shiftKey) { id = 'code'; + } else if (key === 'm' && !event.shiftKey) { + id = 'feedback'; } if (id) { event.preventDefault(); diff --git a/src/app/appPanels.ts b/src/app/appPanels.ts index 34fd2e4c..5e045eae 100644 --- a/src/app/appPanels.ts +++ b/src/app/appPanels.ts @@ -13,4 +13,5 @@ export const CmsView = lazyPanel(() => import('../features/cms/CmsView')); export const ContentView = lazyPanel(() => import('../features/content/ContentView')); export const VariablesPanel = lazyPanel(() => import('../features/variables/VariablesPanel')); export const VariablesView = lazyPanel(() => import('../features/variables/VariablesView')); +export const FeedbackPanel = lazyPanel(() => import('../features/feedback/FeedbackPanel')); export const CodePanel = lazyPanel(() => import('../features/code/CodePanel')); diff --git a/src/app/appTypes.ts b/src/app/appTypes.ts index 16c29103..4042d231 100644 --- a/src/app/appTypes.ts +++ b/src/app/appTypes.ts @@ -51,6 +51,7 @@ export type LeftTab = | 'variables' | 'code' | 'history' + | 'feedback' | undefined; export type RightTab = 'style' | 'settings'; export type DevStatus = 'off' | 'starting' | 'on'; diff --git a/src/app/shell/AppPanes.tsx b/src/app/shell/AppPanes.tsx index 893e676b..228e0824 100644 --- a/src/app/shell/AppPanes.tsx +++ b/src/app/shell/AppPanes.tsx @@ -59,6 +59,8 @@ export function CanvasPane({ app }: { readonly app: ShellView }) { navigatorSelectionTick={app.navigatorSelectionTick} navHoverPath={app.pathFor(hoverNodeId)} overlayInfo={app.overlayInfo} + pins={app.feedback.pins} + onSelectPin={(pin) => selectCanvasPath(app, pin.path, { outside: false })} focusPath={app.focusPath} focusOcc={app.focusOcc} focusWhole={app.focusWhole} diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index dd967146..6d1c059b 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -33,7 +33,7 @@ import type { HistoryCommit, HistoryCommitFile } from '../../features/history/hi import { currentDesktopPlatform, shortcutLabel } from '../../lib/shortcutLabel'; import { type LeftTab } from '../appTypes'; import { openExternalURL, restoreProjectFile, restoreProjectVersion } from '../../ipc/appBridge'; -import { CmsPanel, VariablesPanel, CodePanel } from '../appPanels'; +import { CmsPanel, VariablesPanel, CodePanel, FeedbackPanel } from '../appPanels'; import { CanvasPane, CanvasCovers, @@ -42,6 +42,7 @@ import { CodeWindowHost, ConflictNotice, BusyOverlay, + selectCanvasPath, } from './AppPanes'; import type { AppView, ShellView } from '../App'; @@ -332,6 +333,7 @@ export function LeftPanel({ app, tab }: { readonly app: ShellView; readonly tab: )} {tab === 'code' && } {tab === 'history' && } + {tab === 'feedback' && } {tab === 'assets' && ( { + selectCanvasPath(app, pin.path, { outside: false }); + // Selecting reveals the node in the Navigator; a note's row keeps its panel. + app.setLeftTab('feedback'); + }} + showToast={app.showToast} + /> + ); +} + export function HistoryTab({ app }: { readonly app: ShellView }) { return ( ['feedback']; + /** What a new note attaches to: the selected node's name, or the page. */ + readonly target: string | undefined; + /** The current selection's leaf key; its rows are highlighted. */ + readonly selectedKey: string | undefined; + readonly onSelectPin: (pin: FeedbackPin) => void; + readonly showToast: (message: string, kind: 'success' | 'error') => void; +} + +// Notes pinned to canvas selections, kept in .stacki/feedback.json for a coding +// agent: select an element, type, press Enter; Copy for agent hands them over. +export default function FeedbackPanel(props: FeedbackPanelProps) { + const { feedback } = props; + const [draft, setDraft] = useState(''); + const { reload } = feedback; + // Opening the tab catches up with what an agent changed in the file. + useEffect(() => void reload(), [reload]); + const open = feedback.items.filter((item) => !item.done); + const done = feedback.items.filter((item) => item.done); + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Enter' || event.shiftKey) { + return; + } + event.preventDefault(); + const note = draft.trim(); + if (note) { + setDraft(''); + void feedback.add(note); + } + }; + const copy = async (): Promise => { + try { + await navigator.clipboard.writeText(renderHandoff(feedback.items)); + props.showToast('Copied. Paste it into Claude Code in the project.', 'success'); + } catch { + props.showToast('Could not copy the feedback.', 'error'); + } + }; + return ( +
+
+

Feedback

+ +
+
+