Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions electron/handlers/feedbackHandlers.ts
Original file line number Diff line number Diff line change
@@ -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<MainHost, 'assertInProject' | 'ipcMain'>): 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');
}
7 changes: 4 additions & 3 deletions electron/lib/projectExclusions.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -903,6 +904,7 @@ const host: MainHost = {
registerAssetHandlers(host);
registerCmsHandlers(host);
registerComponentHandlers(host);
registerFeedbackHandlers(host);
registerContentHandlers(host);
registerCssVariableHandlers(host);
registerGitHandlers(host);
Expand Down
1 change: 1 addition & 0 deletions electron/preload/frameQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ const track = (data: Record<string, unknown>, 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
Expand Down
6 changes: 6 additions & 0 deletions electron/preload/frameRegions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
110 changes: 106 additions & 4 deletions electron/preload/frameTargets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string>(); // file → that deepest marker path
const reach = new Map<string, number>(); // 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<string, string>();
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
Expand Down Expand Up @@ -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 <slot>) has no
// in-scope marker, and the app opens the layout for those.
Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions electron/preload/preloadBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PreloadBridge>;
}

Expand Down
50 changes: 50 additions & 0 deletions shared/ipc/feedback.ts
Original file line number Diff line number Diff line change
@@ -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 (`<file>#<index path>`: 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<typeof parseFeedbackItem>;

/** 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 };
}
3 changes: 3 additions & 0 deletions shared/ipc/ipcPayloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)) }),
Expand Down
Loading