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
7 changes: 7 additions & 0 deletions apps/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ async function main(): Promise<void> {
},
});
};
// Surface the canvas-side worktree selection (if any) into the
// dispatch log so a user inspecting a run can see where it ran.
// The runApplyEdit pipeline doesn't yet honour the hint as cwd —
// that's a follow-up; logging it is the first step.
if (d.worktreeHint && d.worktreeHint.trim()) {
emitProgress(`worktree hint: ${d.worktreeHint.trim()}`);
}
try {
const result = await runApplyEdit(
{
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/routes/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,17 @@ export async function registerCommentRoutes(app: FastifyInstance): Promise<void>
async (req, reply) => {
const user = requireUser(req);
const body = req.body;
if (!body?.boardId || !body?.frameId || !body?.text) {
// text is REQUIRED but may be an empty string — the pin-drop flow
// creates the comment optimistically with `text: ''` and the
// compose popover PATCHes the body in afterwards. Rejecting empty
// text here would block that handshake (the symptom: pin appears,
// popover never opens because the server 400's, and the rollback
// tears the optimistic comment out of the store).
if (
!body?.boardId ||
!body?.frameId ||
typeof body?.text !== 'string'
) {
return reply.code(400).send({ error: 'Invalid comment body', code: 'BAD_REQUEST' });
}
// Comments require membership; even viewers can leave them in this MVP.
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/routes/dispatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function registerDispatchRoutes(app: FastifyInstance): Promise<void
return reply.code(403).send({ error: 'Not a member of this board', code: 'FORBIDDEN' });
}

const dispatch = await insertDispatch({
const persisted = await insertDispatch({
id: newId('d'),
boardId: body.boardId,
frameId: body.frameId,
Expand All @@ -43,6 +43,14 @@ export async function registerDispatchRoutes(app: FastifyInstance): Promise<void
baseCommitSha: body.baseCommitSha,
intent: body.intent,
});
// Attach the worktreeHint in-flight only. The dispatches table doesn't
// have a column for it yet — a future schema migration will move this
// to persistence so DLQ retries after a process restart can still
// honour the hint. The MCP bridge tolerates a missing hint gracefully.
const dispatch =
body.worktreeHint && body.worktreeHint.trim()
? { ...persisted, worktreeHint: body.worktreeHint.trim() }
: persisted;

hub.broadcast(dispatch.boardId, { type: 'dispatch.created', dispatch });

Expand Down
49 changes: 38 additions & 11 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import type {
} from '@foldo/protocol';
import { Canvas, type CanvasHandle, type ViewportState } from './components/Canvas';
import { TopBar } from './components/TopBar';
import { LeftRail } from './components/LeftRail';
/* A+W1 touch */ import { PhoneNotSupportedBanner } from './components/PhoneNotSupportedBanner';
/* A+W1 touch */ import { useMediaQuery } from './hooks/useMediaQuery';
import { ToastStack, useToastQueue } from './components/ToastQueue';
Expand All @@ -44,6 +43,7 @@ import {
registerToastHook,
registerSetToolHook,
registerSelectFrameHook,
registerFitToHook,
/* A+W1 features — layer-nav delete/rename/reorder window hooks. */
registerLayerActionHooks,
/* A+W4 features — let plugins/tests read the current tool without
Expand All @@ -64,6 +64,8 @@ import { useFrameViewport } from './hooks/useFrameViewport';
import { useTopBarHandlers } from './hooks/useTopBarHandlers';
import { makeOfflineDispatchSim } from './hooks/useOfflineDispatchSim';
import { FrameLayer } from './components/FrameLayer';
import { SoloBanner } from './components/SoloBanner';
import { useSelectionSlice } from './state/selectionStore';
import { ZoomControl } from './components/ZoomControl';
import {
BootLoadingOverlay,
Expand Down Expand Up @@ -180,6 +182,10 @@ export default function App() {
}, []);
const [selectedElement, setSelectedElementRaw] =
useState<SelectedElement | null>(null);
// Solo branch id (from the client-side selection store). FrameLayer
// skips non-matching branches when this is non-null; SoloBanner shows
// the exit affordance.
const soloBranchId = useSelectionSlice((s) => s.soloBranchId);
// activeDispatchId lives in useDispatchFlow now.
const [viewport, setViewport] = useState<ViewportState>({
x: 0,
Expand Down Expand Up @@ -303,6 +309,34 @@ export default function App() {
});
}, [snap.board, navigate, fitToFrame]);

// Fit-to-rect hook for the Branches plugin. Same pattern as
// registerSelectFrameHook above — the plugin computes the world rect
// covering the selected branch's frames and asks the canvas to fit it.
useEffect(() => {
registerFitToHook((rect) => {
canvasRef.current?.fitTo(rect);
});
return () => registerFitToHook(null);
}, []);

// Defensive: localStorage may carry a soloBranchId from an earlier session
// that points at a branch the current board doesn't have (e.g. switching
// boards, after a seed reset). Without this cleanup, FrameLayer's Solo
// filter would hide every frame on the canvas — and a user landing on the
// board would see an empty canvas with no obvious way to recover.
useEffect(() => {
if (!snap.hydrated) return;
if (!soloBranchId) return;
if (!snap.branches.has(soloBranchId)) {
// Lazy import to avoid pulling selectionStore into the App.tsx
// top-level closure chain.
import('./state/selectionStore').then(({ selectionStore }) => {
selectionStore.setSoloBranch(null);
selectionStore.setSelectedBranch(null);
});
}
}, [snap.hydrated, snap.branches, soloBranchId]);

/* A+W1 features — layer-nav action hooks. The Layer Navigator's
toolbar (touch agent's surface) calls window.__foldoDeleteFrame /
__foldoRenameFrame / __foldoReorderFrame; we own the implementations
Expand Down Expand Up @@ -688,6 +722,7 @@ export default function App() {
zoom={viewport.zoom}
commentsByFrame={commentsByFrame}
inViewportSet={inViewportSet}
soloBranchId={soloBranchId}
onSelectElement={onSelectElement}
onDropPin={handleDropPin}
onCommentClick={handleCommentClick}
Expand All @@ -708,19 +743,11 @@ export default function App() {
wsStatus={snap.wsStatus}
offline={boot.kind === 'offline'}
/>
{/* A+W1 features — `onChange` was a dead prop; the plugin tools
route through window.__foldoSetTool. */}
<LeftRail tool={tool} />
{/*
Plugin substrate slots (Step 9). LeftPanel / RightPanel / PluginToolBar
render nothing if no plugin contributes to them, so today they're
invisible — Step 10's Layer Navigator + Step 11's DOM Editor light them
up. Mounted alongside the existing LeftRail / EditPanel / TestsPanel
rather than replacing them so the substrate ships with zero UX change.
*/}
{/* Plugin substrate slots. PluginToolBar is the canonical tool dock. */}
<LeftPanel />
<RightPanel />
<PluginToolBar />
<SoloBanner />
<input
ref={frameTools.imageInputRef}
type="file"
Expand Down
53 changes: 50 additions & 3 deletions apps/web/src/components/AppFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,25 +57,43 @@ export function AppFrame({
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);

// Tracks whether the iframe has finished loading the sample-app document.
// Before this flips true the iframe.contentWindow is still on about:blank
// (which inherits the parent's origin), and a postMessage with the
// expected localhost:5174 targetOrigin throws "Recipient has origin
// localhost:5173" — noisy in the console even when the parent has a
// try/catch around postMessage. Gate every postToFrame on this ref so
// we only post once the iframe is on its real origin; `foldo.sample.ready`
// covers the "iframe already loaded by the time we mounted" case.
const iframeLoadedRef = useRef<boolean>(false);

const iframeUrl = useMemo(() => buildIframeUrl(content, frame.commitSha), [
content,
frame.commitSha,
]);

// Reset the loaded-ref whenever the iframe is about to navigate (URL
// change) or remount (inViewport flips). The new document hasn't
// attached its postMessage listener yet, so any premature postToFrame
// would hit the about:blank race again.
useEffect(() => {
iframeLoadedRef.current = false;
}, [iframeUrl, inViewport]);

const reviewMode = !testMode;

// Sync review mode + overrides into the iframe whenever they change.
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || !inViewport) return;
const send = () => postToFrame(iframe, { type: 'foldo.sample.setReviewMode', enabled: reviewMode });
// try once; sample app emits "ready" once mounted and we also push on load
send();
if (!iframeLoadedRef.current) return; // wait for onLoad / ready
postToFrame(iframe, { type: 'foldo.sample.setReviewMode', enabled: reviewMode });
}, [reviewMode, inViewport]);

useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || !inViewport) return;
if (!iframeLoadedRef.current) return; // wait for onLoad / ready
if (!content.overrides) return;
postToFrame(iframe, {
type: 'foldo.sample.setOverrides',
Expand Down Expand Up @@ -103,6 +121,10 @@ export function AppFrame({
const msg = ev.data as SampleAppOutbound;
switch (msg.type) {
case 'foldo.sample.ready':
// Sample-app has loaded its document at the real origin.
// Flip the ready ref so subsequent reviewMode / overrides
// effects can postMessage without hitting the about:blank race.
iframeLoadedRef.current = true;
// Push the current review mode + overrides.
postToFrame(iframe, {
type: 'foldo.sample.setReviewMode',
Expand Down Expand Up @@ -234,6 +256,31 @@ export function AppFrame({
// Permit our origin parent to talk to it
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
loading="lazy"
onLoad={() => {
// Backstop for the rare case where 'foldo.sample.ready'
// never fires (older sample-app build, error in its init).
// The native iframe load event means contentWindow is on
// the real origin, so postMessage is safe.
iframeLoadedRef.current = true;
const iframe = iframeRef.current;
if (!iframe) return;
postToFrame(iframe, {
type: 'foldo.sample.setReviewMode',
enabled: reviewMode,
});
if (content.overrides) {
postToFrame(iframe, {
type: 'foldo.sample.setOverrides',
overrides: content.overrides as unknown as Record<
string,
string | boolean
>,
});
}
}}
onError={() => {
iframeLoadedRef.current = false;
}}
/>
) : (
<FramePlaceholder content={content} />
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/BootOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function FirstRunHint({ count }: { count: number }): JSX.Element | null {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<div className="pointer-events-auto absolute bottom-4 right-4 z-40 w-[300px] rounded-xl border border-hairline bg-panel p-3.5 shadow-panel fade-in">
<div className="pointer-events-auto absolute bottom-4 right-4 z-[95] w-[300px] rounded-xl border border-hairline bg-panel p-3.5 shadow-panel fade-in">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-accent">
<Sparkle /> Try this
Expand Down
76 changes: 70 additions & 6 deletions apps/web/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,22 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
[setZoomAtAnchor, viewport, zoomToFit, fitTo, panTo, screenToWorld],
);

// Latest viewport.zoom in a ref so the wheel listener can read it without
// re-binding on every zoom event. The previous implementation listed
// viewport.zoom in the effect deps, which meant the listener was
// removed + re-added on every wheel tick. Under a fast trackpad pinch
// that produces 60+ events/sec, the brief teardown window could drop
// preventDefault on an event, letting the browser native page-zoom
// through and pushing the toolbars out of the layout viewport.
const zoomRef = useRef(viewport.zoom);
zoomRef.current = viewport.zoom;

// Wheel handler: ctrl/meta = zoom (anchored at the cursor), plain = pan.
// Bound to `window`, not just the canvas element — otherwise ctrl/meta+wheel
// while the cursor is over a toolbar overlay (the left rail, top bar, zoom
// control) is never preventDefault'd and falls through to the browser's
// native page-zoom, which leaves the whole app looking zoomed/shifted.
// while the cursor is over a toolbar overlay (the side panel, top bar,
// zoom control) is never preventDefault'd and falls through to the
// browser's native page-zoom, which leaves the whole app looking
// zoomed/shifted.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
Expand All @@ -198,7 +209,10 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
// wherever the cursor happens to be.
e.preventDefault();
const factor = Math.exp(-e.deltaY * 0.01);
setZoomAtAnchor(viewport.zoom * factor, { x: e.clientX, y: e.clientY });
setZoomAtAnchor(zoomRef.current * factor, {
x: e.clientX,
y: e.clientY,
});
return;
}
// Plain wheel = pan, but only when the cursor is actually over the
Expand Down Expand Up @@ -226,8 +240,58 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
}
};
window.addEventListener('wheel', onWheel, { passive: false });
return () => window.removeEventListener('wheel', onWheel);
}, [viewport.zoom, setZoomAtAnchor]);

// Safari proprietary gesture API. During a trackpad pinch, Safari
// fires gesturestart/change/end and SUPPRESSES the wheel-with-ctrlKey
// events that Chrome uses — so we can't just preventDefault these and
// rely on the wheel branch. Instead, drive zoom directly from the
// gesture's `scale` property:
//
// gesturestart — snapshot the current zoom + anchor.
// gesturechange — apply `startZoom * e.scale` anchored at the snap.
// gestureend — clear the snapshot.
//
// preventDefault is called on every gesture event to block Safari's
// visual-viewport zoom (which would push the position:fixed chrome
// out of the visible viewport — the original "lost toolbar" bug).
//
// GestureEvent isn't in TypeScript's lib (Safari-only proprietary
// API), so we type the event minimally inline.
type GestureEventLike = Event & {
scale: number;
clientX: number;
clientY: number;
};
let gestureStartZoom = 0;
let gestureStartAnchor = { x: 0, y: 0 };
const onGestureStart = (e: Event): void => {
e.preventDefault();
const ge = e as GestureEventLike;
gestureStartZoom = zoomRef.current;
gestureStartAnchor = { x: ge.clientX, y: ge.clientY };
};
const onGestureChange = (e: Event): void => {
e.preventDefault();
const ge = e as GestureEventLike;
if (gestureStartZoom === 0 || !Number.isFinite(ge.scale)) return;
setZoomAtAnchor(gestureStartZoom * ge.scale, gestureStartAnchor);
};
const onGestureEnd = (e: Event): void => {
e.preventDefault();
gestureStartZoom = 0;
};
window.addEventListener('gesturestart', onGestureStart, { passive: false });
window.addEventListener('gesturechange', onGestureChange, { passive: false });
window.addEventListener('gestureend', onGestureEnd, { passive: false });

return () => {
window.removeEventListener('wheel', onWheel);
window.removeEventListener('gesturestart', onGestureStart);
window.removeEventListener('gesturechange', onGestureChange);
window.removeEventListener('gestureend', onGestureEnd);
};
// setZoomAtAnchor is stable across renders (useCallback in this file).
}, [setZoomAtAnchor]);

// Pan dragging
const handMode = tool === 'hand' || spaceDown;
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/components/CommentPin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,20 @@ export function CommentPin({ comment, frameSize, onClick }: Props) {
onClick();
}}
/* A+W1 touch: 44x44 (h-11 w-11) hit area; visual pin stays 24x24 inside.
Was h-7 w-7 (~28x28) which is below Apple's 44pt minimum. */
Was h-7 w-7 (~28x28) which is below Apple's 44pt minimum.
filter:drop-shadow on the hit-area wrapper traces the teardrop's
actual outline (rounded-bl-none) instead of a rectangular box-
shadow halo, which read as off on the light markdown background. */
className="absolute z-30 flex h-11 w-11 -translate-x-1/2 -translate-y-full items-center justify-center"
style={{ left: cx, top: cy }}
style={{
left: cx,
top: cy,
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.25))',
}}
aria-label={`Comment by ${comment.authorName}`}
>
<span
className="relative flex h-6 w-6 items-center justify-center rounded-full rounded-bl-none text-[11px] font-semibold text-white shadow-pin"
className="relative flex h-6 w-6 items-center justify-center rounded-full rounded-bl-none text-[11px] font-semibold text-white"
style={{ background: comment.authorColor }}
>
{comment.authorInitial}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/CommentPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function CommentPopover({
<div
data-testid="foldo-comment-popover"
data-foldo-comment-id={comment.id}
className="fade-in pointer-events-auto absolute z-[60] rounded-xl border border-hairline bg-panel shadow-panel"
className="fade-in pointer-events-auto absolute z-[140] rounded-xl border border-hairline bg-panel shadow-panel"
/* A+W1 touch: width follows the W computed above so narrow viewports get
a fitted popover instead of clipping off-screen. */
style={{ left, top, width: W }}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/EditPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function EditPanel({
<div
data-testid="foldo-edit-panel"
data-foldo-dispatch-status={dispatchStatus}
className="fade-in pointer-events-auto absolute right-3 top-16 bottom-16 z-50 flex w-[420px] flex-col rounded-xl border border-hairline bg-panel shadow-panel"
className="fade-in pointer-events-auto absolute right-3 top-16 bottom-16 z-[140] flex w-[420px] flex-col rounded-xl border border-hairline bg-panel shadow-panel"
>
<Header
branch={branch}
Expand Down
Loading
Loading