Skip to content
Closed
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
61 changes: 61 additions & 0 deletions electron/handlers/feedbackHandlers.ts
Original file line number Diff line number Diff line change
@@ -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<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);
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;
}
2 changes: 2 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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 @@ -901,6 +902,7 @@ const host: MainHost = {
registerAssetHandlers(host);
registerCmsHandlers(host);
registerComponentHandlers(host);
registerFeedbackHandlers(host);
registerContentHandlers(host);
registerCssVariableHandlers(host);
registerGitHandlers(host);
Expand Down
3 changes: 3 additions & 0 deletions electron/preload/preloadBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,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
47 changes: 47 additions & 0 deletions shared/ipc/feedback.ts
Original file line number Diff line number Diff line change
@@ -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 (`<file>#<index path>`: 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<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 @@ -6,6 +6,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 @@ -169,6 +170,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
3 changes: 3 additions & 0 deletions shared/ipc/ipcResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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,
Expand Down Expand Up @@ -348,6 +349,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 & {
Expand Down
2 changes: 2 additions & 0 deletions shared/ipc/preloadApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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';
Expand Down
6 changes: 5 additions & 1 deletion src/app/LeftRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
VariableIcon,
CodeIcon,
HistoryIcon,
CommentIcon,
} from '../ui/Icons';

const TABS = [
Expand All @@ -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: 'History', shortcut: '⌥H', Icon: HistoryIcon },
{ id: 'feedback', title: 'Feedback', shortcut: 'M', Icon: CommentIcon },
] as const;
export type RailTab = (typeof TABS)[number]['id'];

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/app/appPanels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
1 change: 1 addition & 0 deletions src/app/appTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type LeftTab =
| 'variables'
| 'code'
| 'history'
| 'feedback'
| undefined;
export type RightTab = 'style' | 'settings';
export type DevStatus = 'off' | 'starting' | 'on';
Expand Down
2 changes: 2 additions & 0 deletions src/app/shell/AppPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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}
Expand Down
20 changes: 19 additions & 1 deletion src/app/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import type {
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,
Expand All @@ -45,6 +45,7 @@ import {
ConflictNotice,
BusyOverlay,
Toast,
selectCanvasPath,
} from './AppPanes';
import type { AppView, ShellView } from '../App';

Expand Down Expand Up @@ -324,6 +325,7 @@ export function LeftPanel({ app, tab }: { readonly app: ShellView; readonly tab:
)}
{tab === 'code' && <CodeTab app={app} />}
{tab === 'history' && <HistoryTab app={app} />}
{tab === 'feedback' && <FeedbackTab app={app} />}
{tab === 'assets' && (
<AssetsPanel
project={app.project}
Expand Down Expand Up @@ -521,6 +523,22 @@ export function CodeTab({ app }: { readonly app: ShellView }) {
);
}

export function FeedbackTab({ app }: { readonly app: ShellView }) {
return (
<FeedbackPanel
feedback={app.feedback}
target={app.overlayInfo(app.pathFor(app.selectedId) ?? '')?.label}
selectedKey={app.selectionKeysRef.current.at(-1)}
onSelectPin={(pin) => {
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 (
<HistoryPanel
Expand Down
10 changes: 10 additions & 0 deletions src/app/state/appComposers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
useSelectionPaths,
useOverlayInfo,
} from './appViewModel';
import { useFeedback } from '../../features/feedback/feedbackState';

// Every piece of the app's state and behaviour, in the order its hooks run.
export function useAppScope() {
Expand All @@ -45,6 +46,14 @@ export function useAppScope() {
const propertyEdits = usePropertyEdits(coreState, lifecycle, history, nodeEdits);
const pageOps = usePageOps(coreState, lifecycle, navigation, history, nodeEdits, shortcuts);
const viewModel = useViewModel(coreState, lifecycle, shortcuts, pageOps);
// After the view model: it sets the selection keys this render pins notes to.
const feedback = useFeedback({
projectPath: coreState.project?.path,
selectionKeys: coreState.selectionKeysRef.current,
editedRel: viewModel.editedRel,
label: viewModel.overlayInfo(viewModel.pathFor(coreState.selectedId) ?? '')?.label,
showToast: lifecycle.showToast,
});
return {
...coreState,
...lifecycle,
Expand All @@ -55,6 +64,7 @@ export function useAppScope() {
...propertyEdits,
...pageOps,
...viewModel,
...feedback,
};
}

Expand Down
Loading