diff --git a/electron/handlers/feedbackHandlers.ts b/electron/handlers/feedbackHandlers.ts new file mode 100644 index 00000000..c729f287 --- /dev/null +++ b/electron/handlers/feedbackHandlers.ts @@ -0,0 +1,107 @@ +// 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 { readSource } from '../lib/mainLimits'; +import { ensureExcluded } from '../preview/previewWorktree'; +import type { MainHost } from './mainHost'; + +const FEEDBACK_FILE = path.join('.stacki', 'feedback.json'); + +// What a project's agent instructions say, so an agent started there without +// the handoff paste still finds the notes. The file name is also the mark that +// the note is already in. +const AGENT_NOTE_MARK = '.stacki/feedback.json'; +const AGENT_NOTE = [ + '## Stacki feedback', + '', + 'Visual feedback left in the Stacki editor is in `.stacki/feedback.json`. Open items', + 'have `"done": false`. Each item\'s `trail` lists `file:line` pointers relative to this', + 'folder: the last entry is the element, the earlier ones the page and the component', + 'instances around it. When an item is addressed, set its `"done"` to `true` and leave', + 'everything else in the file as it is.', + '', +].join('\n'); + +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); + ensureAgentNote(projectPath, assertInProject); + 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; +} + +// CLAUDE.md is what Claude Code reads, so it gets the note, created when +// missing; AGENTS.md gets it only when the project keeps one. A CLAUDE.md that +// imports AGENTS.md already has it from there. +function ensureAgentNote(projectPath: string, inProject: (file: string) => string): void { + const agents = inProject(path.join(projectPath, 'AGENTS.md')); + const claude = inProject(path.join(projectPath, 'CLAUDE.md')); + const hasAgents = fs.existsSync(agents); + if (hasAgents) { + appendAgentNote(agents); + } + const claudeText = fs.existsSync(claude) ? readSource(claude) : ''; + if (hasAgents) { + if (claudeText.includes('@AGENTS.md')) { + return; + } + } + appendAgentNote(claude); +} + +function appendAgentNote(file: string): void { + const text = fs.existsSync(file) ? readSource(file) : ''; + if (text.includes(AGENT_NOTE_MARK)) { + return; + } + const separator = text === '' ? '' : text.endsWith('\n') ? '\n' : '\n\n'; + writeProjectText(file, text + separator + AGENT_NOTE); + assert(readSource(file).includes(AGENT_NOTE_MARK), 'The agent note is in the file'); +} diff --git a/electron/lib/projectExclusions.ts b/electron/lib/projectExclusions.ts index b4ef5d95..d44a9e59 100644 --- a/electron/lib/projectExclusions.ts +++ b/electron/lib/projectExclusions.ts @@ -1,10 +1,11 @@ // Git's local exclude is repository bookkeeping, never website source. import * as fs from 'node:fs'; import * as path from 'node:path'; -const EXCLUDE_LINES = ['.stacki/preview/', '.stacki/canvas/']; +const EXCLUDE_LINES = ['.stacki/preview/', '.stacki/canvas/', '.stacki/feedback.json']; /** - * Keep generated preview checkouts and defaults canvases out of project commits. + * Keep generated preview checkouts, defaults canvases and the editor's + * feedback notes out of project commits. * * Without this the preview checkout shows up as an untracked folder in the * user's own project — in the file picker, in the commit box, in every status @@ -54,7 +55,7 @@ export function ensureExcluded(projectPath: string): boolean { const next = missing.length === 0 ? current - : current + sep + '# Stacki previews (generated).\n' + missing.join('\n') + '\n'; + : current + sep + '# Stacki local files (generated).\n' + missing.join('\n') + '\n'; fs.writeFileSync(file, next); return true; } catch { 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/frameQueries.ts b/electron/preload/frameQueries.ts index ac34604b..c4a69e0f 100644 --- a/electron/preload/frameQueries.ts +++ b/electron/preload/frameQueries.ts @@ -360,6 +360,7 @@ const track = (data: Record, paths: unknown[]) => { scope: typeof data['scope'] === 'string' ? data['scope'] : '', focus: typeof data['focus'] === 'string' ? data['focus'] : '', occurrence: typeof data['focusOcc'] === 'number' ? data['focusOcc'] : 0, + annotating: data['annotate'] === true, }); resetFocusCache(); resetThinCache(); // scope decides what's hit-testable diff --git a/electron/preload/frameRegions.ts b/electron/preload/frameRegions.ts index 4349ea09..88a42737 100644 --- a/electron/preload/frameRegions.ts +++ b/electron/preload/frameRegions.ts @@ -43,6 +43,9 @@ export const inScope = (nodePath: string) => // page. Its siblings stay as dim and as unclickable as the rest of it. export let focusPath = ''; export let focusOcc = 0; +// The Feedback tab is open: a click annotates whatever element is under the +// pointer, in any file, instead of selecting within the open one. +export let annotating = false; // A run whose every node has left the document isn't an instance any more: // a patched page collects fresh runs and leaves the old ones in the map. const isLive = (run: Node[]) => run.some((node) => node.isConnected); @@ -65,11 +68,14 @@ export function setTracking(next: { readonly scope: string; readonly focus: string; readonly occurrence: number; + /** Absent from callers that never annotate, such as Stacki Studio's client. */ + readonly annotating?: boolean; }): void { trackedPaths = next.paths; activeScope = next.scope; focusPath = next.focus; focusOcc = next.occurrence; + annotating = next.annotating ?? false; } export const focusRoots = (): Node[] | undefined => { if (focusCache !== undefined) { diff --git a/electron/preload/frameTargets.ts b/electron/preload/frameTargets.ts index af9d3402..cf8239b8 100644 --- a/electron/preload/frameTargets.ts +++ b/electron/preload/frameTargets.ts @@ -2,9 +2,10 @@ // tagged elements and the widened thin targets, and the hover, click and // double-click the canvas turns into selection. -import { isElement } from './frameBasics'; +import { PRELOAD_LIMITS, isElement } from './frameBasics'; import { PATH_ATTR, + annotating, announceMapped, collectRegions, inFocus, @@ -174,11 +175,101 @@ const targetAt = (event: MouseEvent): Node | undefined => : undefined) || (event.target instanceof window.Node ? event.target : undefined); // Same resolution for hover, click and dblclick, so what lights up under the -// cursor is exactly what a click selects. -const nodeAtEvent = (event: MouseEvent) => - event.clientX || event.clientY +// cursor is exactly what a click selects. While annotating, the hover is the +// element a click would annotate. +const nodeAtEvent = (event: MouseEvent) => { + if (annotating) { + const annotation = annotationAt(event); + return { path: annotation?.path, occurrence: annotation?.occurrence ?? 0, outside: false }; + } + return event.clientX || event.clientY ? nodeAt(targetAt(event), event.clientX, event.clientY) : nodeAt(targetAt(event)); +}; + +// --- Annotating (the Feedback tab) ----------------------------------------- +// Every element is reachable without drilling in: the deepest tagged element +// under the pointer, whatever file rendered it, with the chain of files it +// renders through — the page, each component instance, then the element. + +interface AnnotationStep { + /** The project-relative component file, or '' for the page. */ + readonly file: string; + readonly path: string; +} + +const annotationAt = (event: MouseEvent) => { + const target = targetAt(event); + let tagged = + target instanceof Element ? (target.closest(`[${PATH_ATTR}]`) ?? undefined) : undefined; + // A synthesised event with no coordinates keeps its target, as nodeAtEvent does. + const located = event.clientX || event.clientY; + const x = located ? event.clientX : undefined; + const y = located ? event.clientY : undefined; + while (tagged && !holdsPoint(tagged, x, y)) { + tagged = taggedAncestor(tagged); + } + const chain = tagged ? annotationChain(tagged) : []; + const leaf = chain.at(-1); + if (!tagged || !leaf) { + return undefined; + } + const path = leaf.file ? `${leaf.file}|${leaf.path}` : leaf.path; + return { path, occurrence: occurrenceOf(path, tagged), chain, label: labelOf(tagged) }; +}; + +// Each file the element renders through has a deepest node around it: the +// nearest tagged ancestor-or-self carrying one of that file's paths, its +// deepest path there. The file whose node wraps the element most tightly is +// the element itself; the others are outer, by how far up the document their +// node still reaches. So a page's section slotted into a layout ends on the +// page (the layout's slot reaches higher), and an element inside a component +// ends on the component (its instance on the page reaches higher). Ties — +// a component's root, which carries its instance's page path — put the page first. +const annotationChain = (element: Element): AnnotationStep[] => { + const nearest = new Map(); // file → that deepest marker path + const reach = new Map(); // marker path → highest level carrying it + let at: Element | undefined = element; + for (let level = 0; at && level < PRELOAD_LIMITS.domDepthMax; level++) { + const here = new Map(); + for (const nodePath of pathsOf(at)) { + const file = fileOf(nodePath); + const had = here.get(file); + if (!nearest.has(file) && (had === undefined || depthOf(nodePath) > depthOf(had))) { + here.set(file, nodePath); + } + reach.set(nodePath, level); + } + for (const [file, nodePath] of here) { + nearest.set(file, nodePath); + } + at = taggedAncestor(at); + } + const steps = [...nearest].map(([file, nodePath]) => ({ + file, + path: file ? nodePath.slice(file.length + 1) : nodePath, + reach: reach.get(nodePath) ?? 0, + })); + steps.sort( + (outer, inner) => inner.reach - outer.reach || pageFirst(outer.file) - pageFirst(inner.file), + ); + return steps.map(({ file, path }) => ({ file, path })); +}; + +const depthOf = (nodePath: string): number => nodePath.split('.').length; +const pageFirst = (file: string): number => (file === '' ? 0 : 1); + +const fileOf = (nodePath: string): string => { + const bar = nodePath.indexOf('|'); + return bar === -1 ? '' : nodePath.slice(0, bar); +}; + +// What the note's row calls the element: its tag and first class, `div.hero`. +const labelOf = (element: Element): string => { + const first = element.classList[0]; + const tag = element.tagName.toLowerCase(); + return first ? `${tag}.${first}` : tag; +}; export const startOutlines = () => { // A page edit now patches the document instead of reloading it, so the @@ -287,6 +378,10 @@ const listenForPointer = () => { } event.preventDefault(); event.stopPropagation(); + // Annotating reaches inside components already: nothing to open. + if (annotating) { + return; + } // Report even when nothing in scope matched: markup the layout renders // itself (nav, footer — anything outside the page's ) has no // in-scope marker, and the app opens the layout for those. @@ -310,6 +405,13 @@ const listenForPointer = () => { } event.preventDefault(); event.stopPropagation(); + if (annotating) { + const annotation = annotationAt(event); + if (annotation) { + postLocated('avb:annotate-node', annotation); + } + return; + } // A click that hits no marked node still reports (no path), with // `outside` saying whether it landed on something the open file simply // doesn't own — which is what the app backs out of a component on. 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..9cd5e2b7 --- /dev/null +++ b/shared/ipc/feedback.ts @@ -0,0 +1,50 @@ +// 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, count, 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 component +// instance the element renders through, then the element). `marker` is where +// the canvas draws the note's pin: the element's marker path and which copy of +// it was clicked. An agent may append its own items with `keys: []` and no +// marker or `createdAt`; the trail is main's to resolve, never read. +export const parseFeedbackItem = object({ + id: text, + keys: list(text), + marker: optional(object({ path: text, occurrence: count })), + 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..04ef8193 100644 --- a/src/app/shell/AppPanes.tsx +++ b/src/app/shell/AppPanes.tsx @@ -12,6 +12,7 @@ import type { Attr } from '../../../shared/page/pageNode'; import { noteText } from '../../editor/treeSelection'; import { canvasClickAction } from '../../editor/canvasClick'; import PreviewPane from '../../features/preview/PreviewPane'; +import type { CanvasFeedback } from '../../features/preview/PreviewOverlays'; import { relativeTime } from '../../features/history/HistoryPanel'; import SaveConflictNotice from '../SaveConflictNotice'; import { nodeAtPath } from '../../editor/editorTree'; @@ -59,6 +60,7 @@ export function CanvasPane({ app }: { readonly app: ShellView }) { navigatorSelectionTick={app.navigatorSelectionTick} navHoverPath={app.pathFor(hoverNodeId)} overlayInfo={app.overlayInfo} + feedback={app.leftTab === 'feedback' ? canvasFeedback(app) : undefined} focusPath={app.focusPath} focusOcc={app.focusOcc} focusWhole={app.focusWhole} @@ -69,7 +71,7 @@ export function CanvasPane({ app }: { readonly app: ShellView }) { onStaleEvent={app.onStaleEvent} onPreviewReload={app.onPreviewReload} onContextPath={context.open} - onSelectPath={(path, info) => selectCanvasPath(app, path ?? undefined, info)} + onSelectPath={(path, info) => onCanvasClick(app, path ?? undefined, info)} onSelectedClasses={app.receiveClasses} onRenderedPaths={app.setRenderedPaths} onNodeStates={app.setNodeStates} @@ -144,6 +146,51 @@ function canvasContextPosition( return { ...point, nodeId: node.id }; } +// While annotating, the frame reports clicks as annotations. A click arrives +// here only from a frame whose script predates annotating (the dev build +// rebuilds it only on a restart): annotate the open file's node it names, +// without reaching inside components, rather than select it. +function onCanvasClick( + app: ShellView, + path: string | undefined, + info: { readonly outside: boolean }, +): void { + if (app.leftTab !== 'feedback') { + selectCanvasPath(app, path, info); + return; + } + if (path === undefined) { + return; + } + const bar = path.indexOf('|'); + app.feedback.compose({ + path, + occurrence: 0, + chain: [ + bar === -1 ? { file: '', path } : { file: path.slice(0, bar), path: path.slice(bar + 1) }, + ], + label: app.overlayInfo(path)?.label ?? 'element', + }); +} + +// The Feedback tab's hold on the canvas: clicks annotate, pins open notes. +function canvasFeedback(app: ShellView): CanvasFeedback { + const { feedback } = app; + return { + pins: feedback.pins, + openPinId: feedback.openPinId, + composer: feedback.composer, + onAnnotate: feedback.compose, + onAdd: (note) => void feedback.add(note), + onCancel: feedback.cancel, + onOpenPin: feedback.setOpenPinId, + revealId: feedback.revealId, + onRevealed: feedback.revealed, + onDone: (id) => void feedback.toggleDone(id), + onRemove: (id) => void feedback.remove(id), + }; +} + // What a click on the canvas MEANT — see canvasClick.js. The canvas answers with a // path or with nothing, and nothing has two causes that want opposite things: a click // the open file doesn't own, and a click on something inside it the canvas couldn't diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index dd967146..58f592a3 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, @@ -332,6 +332,7 @@ export function LeftPanel({ app, tab }: { readonly app: ShellView; readonly tab: )} {tab === 'code' && } {tab === 'history' && } + {tab === 'feedback' && } {tab === 'assets' && ( + ); +} + export function HistoryTab({ app }: { readonly app: ShellView }) { return ( + projectRelativePath(projectPath ?? '', file, window.avb.platform); + const feedback = useFeedback({ + projectPath, + active: coreState.leftTab === 'feedback', + pageRel: pageKey === undefined ? undefined : pageKey.slice(0, pageKey.indexOf('#')), + pageRels: projectPath === undefined ? [] : coreState.scan.pages.map((page) => relOf(page.path)), + openPage: (pageRel) => { + const page = coreState.scan.pages.find((candidate) => relOf(candidate.path) === pageRel); + assert(page !== undefined, 'A note opens only one of the project’s pages'); + // Opening a page reports its own failures. + void navigation.selectPage(page); + }, + showToast: lifecycle.showToast, + }); return { ...coreState, ...lifecycle, @@ -55,6 +76,7 @@ export function useAppScope() { ...propertyEdits, ...pageOps, ...viewModel, + ...feedback, }; } diff --git a/src/features/feedback/FeedbackPanel.tsx b/src/features/feedback/FeedbackPanel.tsx new file mode 100644 index 00000000..61ebe264 --- /dev/null +++ b/src/features/feedback/FeedbackPanel.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import type { TrailedFeedbackItem } from '../../../shared/ipc/feedback'; +import { CheckIcon, CloseIcon } from '../../ui/Icons'; +import { renderHandoff } from './feedbackBridge'; +import type { useFeedback } from './feedbackState'; +import './feedback.css'; + +interface FeedbackPanelProps { + readonly feedback: ReturnType['feedback']; + readonly projectPath: string; + readonly showToast: (message: string, kind: 'success' | 'error') => void; +} + +// The notes left on the canvas, kept in .stacki/feedback.json for a coding +// agent: click an element and type; Copy for agent hands them over. +export default function FeedbackPanel(props: FeedbackPanelProps) { + const { feedback } = props; + const open = feedback.items.filter((item) => !item.done); + const done = feedback.items.filter((item) => item.done); + const copy = async (): Promise => { + try { + await navigator.clipboard.writeText(renderHandoff(feedback.items, props.projectPath)); + props.showToast( + 'Copied. Paste into any agent running locally in your project folder.', + 'success', + ); + } catch { + props.showToast('Could not copy the feedback.', 'error'); + } + }; + return ( +
+
+

Feedback

+ +
+
+

Click an element on the canvas to leave a note on it.

+ {open.map((item, index) => ( + + ))} + {done.length > 0 && ( +
+ Done + +
+ )} + {done.map((item) => ( + + ))} +

.stacki/feedback.json

+
+
+ ); +} + +function FeedbackRow( + props: FeedbackPanelProps & { + readonly item: TrailedFeedbackItem; + readonly number: number | undefined; + }, +) { + const { item, feedback } = props; + const open = feedback.openPinId === item.id ? ' is-selected' : ''; + // A row reveals its note where it was left, opening its page and scrolling + // the canvas to it; a second click closes the note again. + const onClick = (): void => { + if (open) { + feedback.setOpenPinId(undefined); + } else { + feedback.reveal(item.id); + } + }; + return ( +
+ {props.number ?? ''} +
+
{item.label}
+
+ {item.trail.at(-1) ?? ''} +
+
{item.note}
+
+ + +
+ ); +} diff --git a/src/features/feedback/feedback.css b/src/features/feedback/feedback.css new file mode 100644 index 00000000..04ac2a2b --- /dev/null +++ b/src/features/feedback/feedback.css @@ -0,0 +1,75 @@ +/* The Feedback panel, after the History panel's list styles. */ +.feedback-body { + padding: 0 8px 12px; +} +.feedback-hint { + margin-bottom: 8px; + padding: 0 4px; + font-size: 11.5px; + line-height: 1.5; + color: var(--text-faint); +} +.feedback-row { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 6px 4px; + border-radius: 6px; + cursor: pointer; +} +.feedback-row:hover { + background: var(--bg-hover); +} +.feedback-row.is-selected { + background: var(--accent-soft); +} +.feedback-row.is-done { + opacity: 0.45; +} +.feedback-number { + min-width: 14px; + font-size: 10.5px; + font-weight: 600; + color: var(--accent); +} +.feedback-main { + flex: 1; + min-width: 0; + font-size: 11.5px; + line-height: 1.4; +} +.feedback-label { + color: var(--text-dim); + overflow-wrap: anywhere; +} +/* A file:line on its own line, cut with an ellipsis; the row's title has it whole. */ +.feedback-where { + overflow: hidden; + font-family: var(--mono); + font-size: 10px; + color: var(--text-faint); + white-space: nowrap; + text-overflow: ellipsis; +} +.feedback-note { + color: var(--text); + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.feedback-row button { + flex-shrink: 0; + padding: 2px; + color: var(--text-faint); +} +.feedback-done-head, +.feedback-path { + display: flex; + justify-content: space-between; + margin-top: 10px; + padding: 0 4px; + font-size: 10.5px; + color: var(--text-faint); +} +.feedback-path { + font-family: var(--mono); +} diff --git a/src/features/feedback/feedbackBridge.ts b/src/features/feedback/feedbackBridge.ts new file mode 100644 index 00000000..d78ac6d1 --- /dev/null +++ b/src/features/feedback/feedbackBridge.ts @@ -0,0 +1,57 @@ +// The Feedback file through main: read, write, and the replies parsed before +// the panel trusts them. Also the text Copy for agent puts on the clipboard. +import { list, record, text } from '../../../shared/core/boundary'; +import { parseFeedbackItem } from '../../../shared/ipc/feedback'; +import type { FeedbackItem, TrailedFeedbackItem } from '../../../shared/ipc/feedback'; +import { parseIpcPayload } from '../../../shared/ipc/ipcPayloads'; +import type { Result } from '../../../shared/core/result'; +import { cleanError } from '../../lib/cleanError'; + +export function parseFeedbackReply(input: unknown): readonly TrailedFeedbackItem[] { + return list((entry) => ({ + ...parseFeedbackItem(entry), + trail: list(text)(record(entry)['trail']), + }))(record(input)['items']); +} + +export function readFeedback(projectPath: string) { + const payload = parseIpcPayload('feedback:read', { projectPath }); + return feedbackRequest(() => window.avb.readFeedback(payload)); +} + +export function writeFeedback(projectPath: string, items: readonly FeedbackItem[]) { + // The trail is main's to resolve; the payload carries only the item fields. + const payload = parseIpcPayload('feedback:write', { projectPath, items }); + return feedbackRequest(() => window.avb.writeFeedback(payload)); +} + +/** The one paste that starts the agent: the protocol, then each open item. The + * file is named absolutely, so the paste works wherever the agent was started. */ +export function renderHandoff(items: readonly TrailedFeedbackItem[], projectPath: string): string { + const open = items.filter((item) => !item.done); + const lines = open.map((item, index) => { + const where = item.trail.length > 0 ? item.trail.join(' › ') : '(no source location)'; + return `${index + 1}. ${where} (${item.label}): ${item.note}`; + }); + return [ + `Address the open items in ${projectPath}/.stacki/feedback.json (${open.length} open). ` + + `Trails are relative to ${projectPath}. For each item, open every file:line in its ` + + 'trail — the last entry is the element, the earlier ones are the page and the ' + + 'component instances it sits in — make the change, then set that item\'s "done" to ' + + 'true in the file.', + '', + ...lines, + ].join('\n'); +} + +async function feedbackRequest( + invoke: () => Promise, +): Promise> { + let response: unknown; + try { + response = await invoke(); + } catch (error: unknown) { + return { ok: false, error: cleanError(error) }; + } + return { ok: true, value: parseFeedbackReply(response) }; +} diff --git a/src/features/feedback/feedbackState.ts b/src/features/feedback/feedbackState.ts new file mode 100644 index 00000000..73c0bed4 --- /dev/null +++ b/src/features/feedback/feedbackState.ts @@ -0,0 +1,235 @@ +// The Feedback notes the panel and the canvas share. The file is the only +// state: every change reads it fresh, applies the change, and writes it back, +// so an agent's edits made in the meantime (done flips, its own items) survive. +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { assert } from '../../../shared/core/assert'; +import type { Result } from '../../../shared/core/result'; +import type { FeedbackItem, TrailedFeedbackItem } from '../../../shared/ipc/feedback'; +import { readFeedback, writeFeedback } from './feedbackBridge'; + +export interface FeedbackPin { + readonly id: string; + /** The element's marker path, in whichever file rendered it. */ + readonly path: string; + readonly occurrence: number; + /** The item's 1-based number among the open items, as the list shows it. */ + readonly index: number; + readonly note: string; +} + +/** A click on the canvas while annotating: the element and the files it + * renders through, outermost first ('' is the page). */ +export interface FeedbackAnnotation { + readonly path: string; + readonly occurrence: number; + readonly chain: readonly { readonly file: string; readonly path: string }[]; + readonly label: string; +} + +/** What a click on a row has to do to bring its note on screen. */ +export type FeedbackRevealPlan = + | { readonly kind: 'none' } + | { readonly kind: 'here' } + | { readonly kind: 'open-page'; readonly page: string } + | { readonly kind: 'no-page' }; + +interface Composing { + readonly keys: readonly string[]; + readonly marker: { readonly path: string; readonly occurrence: number }; + readonly label: string; +} + +interface FeedbackContext { + readonly projectPath: string | undefined; + /** Whether the Feedback tab is open: notes load, pin and annotate only then. */ + readonly active: boolean; + /** The open page, project-relative: what a chain's '' file means. */ + readonly pageRel: string | undefined; + /** The project's pages, project-relative: which of a note's files is its page. */ + readonly pageRels: readonly string[]; + /** Opens one of `pageRels`. */ + readonly openPage: (pageRel: string) => void; + readonly showToast: (message: string, kind: 'error') => void; +} + +export function useFeedback(context: FeedbackContext) { + const { active, pageRel } = context; + const { items, reload, change } = useFeedbackFile(context); + const [composing, setComposing] = useState(undefined); + const [openPinId, setOpenPinId] = useState(undefined); + // The pin the canvas still has to scroll to: its page may yet be loading. + const [revealId, setRevealId] = useState(undefined); + const pins = useMemo( + () => (active && pageRel ? feedbackPins(items, pageRel) : []), + [active, items, pageRel], + ); + const compose = (annotation: FeedbackAnnotation): void => { + if (!pageRel) { + return; + } + setOpenPinId(undefined); + setComposing({ + keys: annotation.chain.map((step) => `${step.file || pageRel}#${step.path}`), + marker: { path: annotation.path, occurrence: annotation.occurrence }, + label: annotation.label, + }); + }; + const add = (note: string): Promise => { + if (!composing) { + return Promise.resolve(); + } + setComposing(undefined); + return change((previous) => [...previous, newItem(composing, note)]); + }; + const toggleDone = (id: string): Promise => + change((previous) => + previous.map((item) => (item.id === id ? { ...item, done: !item.done } : item)), + ); + const remove = (id: string): Promise => + change((previous) => previous.filter((item) => item.id !== id)); + const clearDone = (): Promise => + change((previous) => previous.filter((item) => !item.done)); + const composer = + active && composing ? { ...composing.marker, target: composing.label } : undefined; + const cancel = (): void => setComposing(undefined); + // A row brings its note on screen: the page it was left on opens when another + // one is showing, and the canvas scrolls to the pin once that page measures it. + const reveal = (id: string): void => { + const item = items.find((candidate) => candidate.id === id); + const plan = feedbackRevealPlan(item, { pageRel, pageRels: context.pageRels }); + if (plan.kind === 'none') { + return; + } + if (plan.kind === 'no-page') { + context.showToast('The page this note was left on is no longer in the project.', 'error'); + return; + } + setComposing(undefined); + setOpenPinId(id); + setRevealId(id); + if (plan.kind === 'open-page') { + context.openPage(plan.page); + } + }; + const revealed = (): void => setRevealId(undefined); + const actions = { reload, compose, add, cancel, toggleDone, remove, clearDone, reveal, revealed }; + return { feedback: { items, pins, composer, openPinId, setOpenPinId, revealId, ...actions } }; +} + +// The file's items while the tab is open: read on opening, on window focus, and +// before every change, which is applied to the fresh read and written back. +function useFeedbackFile({ projectPath, active, showToast }: FeedbackContext) { + const [items, setItems] = useState([]); + // Replies for a project that is no longer open are dropped. + const projectRef = useRef(projectPath); + projectRef.current = projectPath; + const settle = useCallback( + (requested: string, result: Result): void => { + if (projectRef.current !== requested) { + return; + } + if (result.ok) { + setItems(result.value); + } else { + showToast(result.error, 'error'); + } + }, + [showToast], + ); + const reload = useCallback(async (): Promise => { + if (projectPath) { + settle(projectPath, await readFeedback(projectPath)); + } + }, [projectPath, settle]); + const change = useCallback( + async (apply: (items: readonly FeedbackItem[]) => readonly FeedbackItem[]): Promise => { + if (!projectPath) { + return; + } + const current = await readFeedback(projectPath); + const next = current.ok ? await writeFeedback(projectPath, apply(current.value)) : current; + settle(projectPath, next); + }, + [projectPath, settle], + ); + useEffect(() => { + if (!active) { + return undefined; + } + const onFocus = (): void => void reload(); + onFocus(); + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + }, [active, reload]); + return { items, reload, change }; +} + +function newItem(composing: Composing, note: string): FeedbackItem { + return { + id: crypto.randomUUID().slice(0, 8), + keys: [...composing.keys], + marker: composing.marker, + label: composing.label, + note, + createdAt: new Date().toISOString(), + done: false, + }; +} + +/** + * Where a row's note is: nowhere on the canvas (done, or an agent's own item + * without a place), on the open page, on another page that has to open first, + * or on a page the project no longer has. A note's keys are not in page-first + * order (content a page slots into its layout lists the layout first), so the + * page is whichever key names one: the open page by the same rule its pins use, + * otherwise the first key naming any of the project's pages. + */ +export function feedbackRevealPlan( + item: TrailedFeedbackItem | undefined, + pages: { readonly pageRel: string | undefined; readonly pageRels: readonly string[] }, +): FeedbackRevealPlan { + if (item === undefined) { + return { kind: 'none' }; + } + if (item.done) { + return { kind: 'none' }; + } + if (item.marker === undefined) { + return { kind: 'none' }; + } + const files = item.keys.map((key) => { + assert(key.includes('#'), 'A selection key names its file before the #'); + return key.slice(0, key.indexOf('#')); + }); + assert(files.length === item.keys.length, 'Every key names a file'); + if (pages.pageRel !== undefined) { + if (files.includes(pages.pageRel)) { + return { kind: 'here' }; + } + } + const page = files.find((file) => pages.pageRels.includes(file)); + return page === undefined ? { kind: 'no-page' } : { kind: 'open-page', page }; +} + +/** + * The open items that draw on the canvas: those left on the open page that + * know where they were clicked. Everything else (another page, an agent's own + * item) stays in the list without a pin. + */ +export function feedbackPins( + items: readonly TrailedFeedbackItem[], + pageRel: string, +): readonly FeedbackPin[] { + const onPage = (key: string): boolean => key.slice(0, key.indexOf('#')) === pageRel; + const pins: FeedbackPin[] = []; + items + .filter((item) => !item.done) + .forEach((item, position) => { + if (item.marker && item.keys.some(onPage)) { + const { path, occurrence } = item.marker; + pins.push({ id: item.id, path, occurrence, index: position + 1, note: item.note }); + } + }); + assert(pins.length <= items.length, 'At most one pin per item'); + return pins; +} diff --git a/src/features/preview/PreviewOverlays.tsx b/src/features/preview/PreviewOverlays.tsx index 4c3020cb..ddd2d0a3 100644 --- a/src/features/preview/PreviewOverlays.tsx +++ b/src/features/preview/PreviewOverlays.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useState } from 'react'; import type { Box } from './outlineBoxes'; -import type { PreviewMessage } from './previewMessages'; +import type { Annotation, PreviewMessage } from './previewMessages'; import { hoverIsSelection, onePerPlace, outlineBoxPath } from './outlineBoxes'; import { spacingBands, withLiveSpacing } from './spacingBands'; import { @@ -28,6 +28,35 @@ export interface OverlayInfo { readonly bound: boolean; } +/** A Feedback note drawn on the canvas, on one copy of its element. */ +export interface PinOverlay { + readonly id: string; + /** The element's marker path, in whichever file rendered it. */ + readonly path: string; + readonly occurrence: number; + /** The 1-based number the Feedback list shows the note under. */ + readonly index: number; + readonly note: string; +} + +/** The Feedback tab's layer: clicks annotate, notes show as pins, and a box + * takes the note for the element just clicked. */ +export interface CanvasFeedback { + readonly pins: readonly PinOverlay[]; + readonly openPinId: string | undefined; + readonly composer: + { readonly path: string; readonly occurrence: number; readonly target: string } | undefined; + readonly onAnnotate: (annotation: Annotation) => void; + readonly onAdd: (note: string) => void; + readonly onCancel: () => void; + readonly onOpenPin: (id: string | undefined) => void; + /** A pin the Feedback list asked to bring on screen, until the canvas has. */ + readonly revealId: string | undefined; + readonly onRevealed: () => void; + readonly onDone: (id: string) => void; + readonly onRemove: (id: string) => void; +} + export interface SpacingHover { readonly kind: 'padding' | 'margin' | 'gap'; readonly sides?: readonly string[]; @@ -54,6 +83,7 @@ interface PreviewOverlaysProps { readonly focusPath: string | undefined; readonly focusWhole: boolean; readonly overlayInfo?: (path: string) => OverlayInfo | undefined; + readonly feedback?: CanvasFeedback | undefined; } export function PreviewOverlays(props: PreviewOverlaysProps) { @@ -64,6 +94,9 @@ export function PreviewOverlays(props: PreviewOverlaysProps) { )} + {props.feedback && ( + + )} ); } @@ -111,7 +144,9 @@ function SpacingOverlays(props: PreviewOverlaysProps) { function NodeOutlines(props: PreviewOverlaysProps) { const outlines = outlineTargets(props); return outlines.flatMap((outline) => { - const info = props.overlayInfo?.(outline.path); + // An annotation can name an element of any file, which the open file's + // model cannot label: outline it plainly. + const info = props.feedback ? ANNOTATION_OUTLINE : props.overlayInfo?.(outline.path); const geometryPath = outlineBoxPath(outline.path, info?.nodeKind); const all = props.rects[geometryPath] ?? props.rects[outline.path]; if (!all || !info) { @@ -128,10 +163,12 @@ function NodeOutlines(props: PreviewOverlaysProps) { className={`node-outline ${outline.type} ${info.kind}${info.bound ? ' bound' : ''}`} style={boxStyle(box, props.scale)} > - - {outlineIcon(info)} - {info.label} - + {info.label && ( + + {outlineIcon(info)} + {info.label} + + )} )); }); @@ -154,12 +191,25 @@ function outlineTargets(props: PreviewOverlaysProps): readonly OutlineTarget[] { ) { targets.push({ path: props.hoverPath, type: 'hover', occ: props.hoverOcc }); } - if (props.selPath) { + // While annotating, the note box marks what was clicked; the editor's + // selection is not what a click there means. + if (props.selPath && !props.feedback) { targets.push({ path: props.selPath, type: 'sel', occ: props.selOcc }); } return targets; } +const ANNOTATION_OUTLINE: OverlayInfo = { + label: '', + kind: 'element', + tag: undefined, + astroAsset: false, + dynamicTag: false, + nodeKind: 'element', + isLayout: false, + bound: false, +}; + function outlineIcon(info: OverlayInfo): React.ReactNode { const size = 11; if (info.isLayout) { @@ -208,3 +258,119 @@ function spacingLabel(box: Box & { readonly side: string }): string { const horizontal = box.side === 'left' || box.side === 'right'; return `${Math.round(horizontal ? box.w : box.h)}px`; } + +// The Feedback pins, each opening a card with its note, and the box that takes +// a new note under the element just clicked. A pin or a box whose element +// reported no rect (another branch, off the page) draws nothing. +function FeedbackOverlays({ + feedback, + rects, + scale, +}: { + readonly feedback: CanvasFeedback; + readonly rects: RectMap; + readonly scale: number; +}) { + const boxOf = (place: { readonly path: string; readonly occurrence: number }) => { + const all = rects[place.path]; + return all?.[place.occurrence] ?? all?.[0]; + }; + const { composer } = feedback; + const composerBox = composer && boxOf(composer); + return ( + <> + {feedback.pins.flatMap((pin) => { + const box = boxOf(pin); + if (!box) { + return []; + } + const open = feedback.openPinId === pin.id; + return [ +
+ + {open && } +
, + ]; + })} + {composer && composerBox && ( + + )} + + ); +} + +function PinCard({ + pin, + feedback, +}: { + readonly pin: PinOverlay; + readonly feedback: CanvasFeedback; +}) { + return ( +
+

{pin.note}

+
+ + +
+
+ ); +} + +// Enter adds the note, Shift+Enter breaks the line, Escape closes the box. +function NoteComposer(props: { + readonly target: string; + readonly left: number; + readonly top: number; + readonly feedback: CanvasFeedback; +}) { + const [draft, setDraft] = useState(''); + const submit = (): void => { + const note = draft.trim(); + if (note) { + props.feedback.onAdd(note); + } + }; + const onKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Escape') { + // The box's own Escape: the app's (leave the component) must not fire too. + event.stopPropagation(); + props.feedback.onCancel(); + } else if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + submit(); + } + }; + return ( +
+