Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ jobs:
packages/editor/App.htmlChrome.test.tsx
packages/editor/App.decisionControl.test.tsx
packages/review-editor/App.decisionControl.test.tsx
packages/ui/components/TerminalToolsAnnouncementDialog.test.tsx
packages/editor/App.terminalToolsAnnouncement.test.tsx
packages/review-editor/components/ReviewSidebar.generalComment.test.tsx
packages/ui/components/HtmlSurfaceControls.test.tsx
packages/ui/components/AnnotationPanel.unanchored.test.tsx
Expand Down
66 changes: 65 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,71 @@ The default code-review diff is **`since-base`** — a composite of `merge-base(

**Staging display invariant:** `useGitAdd`'s `stagedFiles` is the EFFECTIVE staged set (sections-sidecar snapshot + session stage/unstage overrides) and is the only source any surface may render staging state from. The sidecar entry's `staged` flag is a snapshot — ORing it back in makes files unstaged mid-session render as staged (and inverts the next toggle).

`since-base` is only offered when the base ref actually resolves — on a repo whose trunk isn't discoverable (`trunk`, no `origin/HEAD`) `getGitContext` omits it and the default falls through to `uncommitted`, so committed branch work is never silently hidden. The since-base patch/sections/fingerprint/file-content paths all degrade to `HEAD` together when merge-base fails for a resolvable-but-unrelated base. First-run shows `ReviewSetupDialog` (replaces the removed `DiffTypeSetupDialog`), which initializes an unseen reviewer's panel to Tree once while preserving the resolved diff default, and is reopenable from the review header menu. The one-time dialog chain is guide intro → look-and-feel → review setup → Edit Mode → token hover cards; none of the dialogs stack. The token hover announcement is last and additionally skips a session where hover cards cannot run at all (no live workspace), WITHOUT consuming its cookie, and never shows to a reviewer whose trigger is already non-default (which after the boolean-to-trigger migration is exactly the early adopter who turned cards off). Analysis layers no longer add a startup dialog: Semantic Changes retains its enabled default, while Call Flow remains disabled until the user explicitly enables it in Settings, which is also consent for its managed runtime installation.
`since-base` is only offered when the base ref actually resolves — on a repo whose trunk isn't discoverable (`trunk`, no `origin/HEAD`) `getGitContext` omits it and the default falls through to `uncommitted`, so committed branch work is never silently hidden. The since-base patch/sections/fingerprint/file-content paths all degrade to `HEAD` together when merge-base fails for a resolvable-but-unrelated base. First-run shows `ReviewSetupDialog` (replaces the removed `DiffTypeSetupDialog`), which initializes an unseen reviewer's panel to Tree once while preserving the resolved diff default, and is reopenable from the review header menu. The one-time dialog chain is guide intro → look-and-feel → review setup → Edit Mode → token hover cards → the terminal-tools announcement; none of the dialogs stack. The token hover announcement is last and additionally skips a session where hover cards cannot run at all (no live workspace), WITHOUT consuming its cookie, and never shows to a reviewer whose trigger is already non-default (which after the boolean-to-trigger migration is exactly the early adopter who turned cards off). Analysis layers no longer add a startup dialog: Semantic Changes retains its enabled default, while Call Flow remains disabled until the user explicitly enables it in Settings, which is also consent for its managed runtime installation.

### First-run terminal-tools announcement

A one-time panel announcing Plannotator's two terminal clients, **Plannotator
TUI** (`plannotator/plannotator-tui`) and **Herdr Annotate**
(`plannotator/herdr-annotate`). One cookie covers every surface —
`plannotator-announce-tui-herdr-seen`, value `'1'`, read and written by
`needsTerminalToolsAnnouncement()` / `markTerminalToolsAnnouncementSeen()` in
`packages/ui/utils/terminalToolsAnnouncement.ts` — so dismissing it in plan
review retires it in annotate and code review too, and vice versa. It is a
plain storage key rather than a settings-registry entry for the same reason
`lookAndFeelAnnouncement.ts` is: `configStore.ensureLoaded` seeds every
registry default into a cookie on first access, so a registry-backed flag could
never tell "never seen" from "seeded default".

The component is `packages/ui/components/TerminalToolsAnnouncementDialog.tsx`
(shared shell: portal, `z-[100]`, hand-rolled Escape + Tab wrap + focus
restore, `data-terminal-tools-announcement-dialog`). It is video first: the
real demo footage fills the top of the panel edge to edge, and the text under
it is one headline and one sentence, then one action row (star the two repos,
"Watch on X", "Got it"). There is no mock terminal, no install commands, no
feature list; the repo pages carry all of that. Two deliberate departures from
its siblings: the backdrop dismisses (the panel collects no decision, so there
is nothing to lose by closing it impatiently), and its keydown listener is
registered on the CAPTURE phase and swallows `Mod+Enter`, so a keystroke aimed
at the announcement cannot approve a plan or post a review behind it.

**Media is hosted, not bundled.** The two demos (`tui-herdr-full-demo` and
`tui-herdr-lite-demo`, mp4 + webm + poster jpg each, ~23MB together) live in
`apps/marketing/public/assets/` and are served from
`https://plannotator.ai/assets/` once the marketing deploy syncs them, the same
precedent as `GuideIntroDialog`'s hero image (the Edit Mode recording is small
enough to inline; these are not). The `<video>` is `muted playsInline loop`
with `preload="auto"`, mp4 first in source order, and `autoPlay` unless
`prefers-reduced-motion` matches, in which case the poster waits behind a
play button. A `Full | Lite` segmented switch (`role="tablist"`) swaps the
footage and the "Watch on X" link together; the panel is keyed per demo so
playback and load-failure state reset with it. If neither source can load
(offline), the frame keeps its place and shows a "Watch on X" link over it.
The panel's width follows the viewport HEIGHT as well as its width
(`min(1120px, 100%, (100dvh - 14rem) * 1280/806)`) so the video never
scrolls out of view on a short window. No CSP is involved: the app HTML ships
no `Content-Security-Policy`, and the servers only set one on sandboxed
artifact responses.

**Ordering: LAST in each app's chain, never first.** Code review gates it
through `terminalToolsAnnouncementCanShow` on guide intro, look-and-feel, review
setup, Edit Mode and token hover cards; the plan editor gates it on the
look-and-feel chooser, goal setup and permission-mode setup. The destination
spotlight and the auto-viewed toast were extended to defer behind it too, so
nothing stacks. Last rather than first because none of those dialogs consumes
this cookie: a session busy asking setup questions defers the announcement to
the next load instead of burning it, and that also puts it in front of the right
reader — someone opening Plannotator for the first time is still learning this
app, while the people who should hear that it now runs in a terminal are the
ones who already answered every setup question and see it on their next load.

Suppressed, never consumed, on: archive browsing and read-only shared plans
(which is also what the share portal serves, via `isSharedSession`), the
compact touch shell, and while the initial payload is still loading. The
guides.show portable viewer never mounts either App, so it is unaffected by
construction. `@plannotator/ui` exposes no first-run-suppression seam on
`configurePlannotatorUI`; a host that wants the announcement off installs its
own `storageBackend` (the documented escape hatch) and pre-seeds the key.

### GitButler review invariants

Expand Down
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions packages/core/guide-viewer-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import type { GuideViewerAssets } from "./guide-format";

export const GUIDE_VIEWER_MANIFEST: Omit<GuideViewerAssets, "baseUrl"> = {
js: "viewer.KTNT-M2b.js",
css: "viewer.B0Ro0qg9.css",
css: "viewer.Glgcqvv_.css",
jsIntegrity: "sha384-UGxkmDjeL0LMAKSAnleY0ewq4d4vtotFlHvHYWaK6UGIWmV30DyT5wSKQEz2NHdV",
cssIntegrity: "sha384-nCJAkARK5upOwP8psUDiybAkhN5JKQRTHVPPtwu39SqMNUrg055NzFlTfiTlaWtC",
cssIntegrity: "sha384-AgbiWDVx6GmLkSky/T96ajOdS5OgjBFAm0PL+9COpv1Bo0pfsZbDZwmD8suPZSJJ",
langs: {
"astro": "chunks/astro.Ts5EKq2l.js",
"c": "chunks/c.BIGW1oBm.js",
Expand Down
1 change: 1 addition & 0 deletions packages/editor/App.archiveReadOnly.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const hasDom = typeof document !== "undefined";

if (hasDom) {
document.cookie = "plannotator-look-feel-announcement-seen=2; path=/";
document.cookie = "plannotator-announce-tui-herdr-seen=1; path=/";
document.cookie = "plannotator-vim-mode-announcement-seen=2; path=/";
document.cookie = "plannotator-plan-ai-announcement-seen=1; path=/";
}
Expand Down
1 change: 1 addition & 0 deletions packages/editor/App.compactToc.mobile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const memoryBackend: StorageBackend = {

function seedAnnouncementsSeen(): void {
memory.set("plannotator-look-feel-announcement-seen", "2");
memory.set("plannotator-announce-tui-herdr-seen", "1");
memory.set("plannotator-vim-mode-announcement-seen", "2");
memory.set("plannotator-plan-ai-announcement-seen", "1");
}
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/App.decisionControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const hasDom = typeof document !== "undefined";

if (hasDom) {
document.cookie = "plannotator-look-feel-announcement-seen=2; path=/";
document.cookie = "plannotator-announce-tui-herdr-seen=1; path=/";
document.cookie = "plannotator-vim-mode-announcement-seen=2; path=/";
document.cookie = "plannotator-plan-ai-announcement-seen=1; path=/";
}
Expand All @@ -55,6 +56,7 @@ const memoryBackend: StorageBackend = {

function seedAnnouncementsSeen(): void {
memory.set("plannotator-look-feel-announcement-seen", "2");
memory.set("plannotator-announce-tui-herdr-seen", "1");
memory.set("plannotator-vim-mode-announcement-seen", "2");
memory.set("plannotator-plan-ai-announcement-seen", "1");
}
Expand Down
1 change: 1 addition & 0 deletions packages/editor/App.editModeShortcut.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const memoryBackend: StorageBackend = {

function seedAnnouncementsSeen(): void {
memory.set("plannotator-look-feel-announcement-seen", "2");
memory.set("plannotator-announce-tui-herdr-seen", "1");
memory.set("plannotator-vim-mode-announcement-seen", "2");
memory.set("plannotator-plan-ai-announcement-seen", "1");
}
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/App.htmlChrome.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const hasDom = typeof document !== "undefined";

if (hasDom) {
document.cookie = "plannotator-look-feel-announcement-seen=2; path=/";
document.cookie = "plannotator-announce-tui-herdr-seen=1; path=/";
document.cookie = "plannotator-vim-mode-announcement-seen=2; path=/";
document.cookie = "plannotator-plan-ai-announcement-seen=1; path=/";
}
Expand Down Expand Up @@ -60,6 +61,7 @@ const memoryBackend: StorageBackend = {

function seedAnnouncementsSeen(): void {
memory.set("plannotator-look-feel-announcement-seen", "2");
memory.set("plannotator-announce-tui-herdr-seen", "1");
memory.set("plannotator-vim-mode-announcement-seen", "2");
memory.set("plannotator-plan-ai-announcement-seen", "1");
}
Expand Down
1 change: 1 addition & 0 deletions packages/editor/App.permissionModeSetup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const memoryBackend: StorageBackend = {

function seedAnnouncementsSeen(): void {
memory.set("plannotator-look-feel-announcement-seen", "2");
memory.set("plannotator-announce-tui-herdr-seen", "1");
memory.set("plannotator-vim-mode-announcement-seen", "2");
memory.set("plannotator-plan-ai-announcement-seen", "1");
}
Expand Down
193 changes: 193 additions & 0 deletions packages/editor/App.terminalToolsAnnouncement.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";

const hasDom = typeof document !== "undefined";

const storageModule = hasDom ? await import("@plannotator/ui/utils/storage") : null;
const appModule = hasDom ? await import("./App") : null;
const App = appModule?.default as typeof import("./App")["default"];

const originalFetch = globalThis.fetch;
const originalEventSource = globalThis.EventSource;
const originalMatchMedia = hasDom ? window.matchMedia : undefined;

const ANNOUNCEMENT_KEY = "plannotator-announce-tui-herdr-seen";
const LOOK_AND_FEEL_KEY = "plannotator-plan-look-choice-resolved";
const DIALOG = "[data-terminal-tools-announcement-dialog]";

const memory = new Map<string, string>();
const memoryBackend = {
getItem: (key: string) => memory.get(key) ?? null,
setItem: (key: string, value: string) => void memory.set(key, value),
removeItem: (key: string) => void memory.delete(key),
};

class SilentEventSource {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 2;
readonly CONNECTING = 0;
readonly OPEN = 1;
readonly CLOSED = 2;
readonly readyState = SilentEventSource.OPEN;
readonly url: string;
readonly withCredentials = false;
onerror: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onopen: ((event: Event) => void) | null = null;
constructor(url: string | URL) { this.url = String(url); }
addEventListener(): void {}
close(): void {}
dispatchEvent(): boolean { return true; }
removeEventListener(): void {}
}

interface PlanResponse {
readonly plan: string;
readonly origin: "codex";
readonly mode?: "archive" | "annotate";
readonly filePath?: string;
readonly archivePlans?: readonly {
readonly filename: string;
readonly status: "approved";
readonly timestamp: string;
readonly title: string;
}[];
readonly sharingEnabled: false;
readonly serverConfig: Record<string, never>;
}

let root: Root | null = null;
let host: HTMLElement | null = null;

function stubFetch(planResponse: PlanResponse): typeof fetch {
return async (input, init) => {
const rawUrl = input instanceof Request ? input.url : String(input);
if (rawUrl.startsWith("https://api.github.com/")) return new Response(null, { status: 404 });
const url = new URL(rawUrl, "http://localhost");
if (url.pathname === "/api/plan") return Response.json(planResponse);
if (url.pathname === "/api/archive/plans") {
return Response.json({ plans: planResponse.archivePlans ?? [] });
}
if (url.pathname === "/api/archive/plan") {
return Response.json({ markdown: planResponse.plan, filepath: "saved.md" });
}
if (url.pathname === "/api/ai/capabilities") return Response.json({ available: false, providers: [] });
if (url.pathname === "/api/draft") return Response.json({ error: "Not found" }, { status: 404 });
return Response.json({});
};
}

function useCompactTouchMedia(): void {
window.matchMedia = ((query: string): MediaQueryList => ({
matches: query.includes("max-width") || query.includes("pointer: coarse"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
})) as typeof window.matchMedia;
}

async function mountApp(planResponse: PlanResponse): Promise<void> {
globalThis.fetch = stubFetch(planResponse);
// SAFETY: the App only uses EventSource's constructor, handlers, and close.
globalThis.EventSource = SilentEventSource as unknown as typeof EventSource;
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
await act(async () => { root?.render(<App />); });
for (let attempt = 0; attempt < 20 && !document.body.textContent?.includes("Session document"); attempt += 1) {
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
}
}

async function unmountApp(): Promise<void> {
if (root) await act(async () => root?.unmount());
root = null;
host?.remove();
host = null;
if (hasDom) document.body.replaceChildren();
}

const PLAN: PlanResponse = {
plan: "# Session document\n\nA paragraph to review.",
origin: "codex",
sharingEnabled: false,
serverConfig: {},
};

describe.if(hasDom)("terminal tools announcement in the plan editor", () => {
beforeEach(() => {
memory.clear();
storageModule?.setStorageBackend(memoryBackend);
});

afterEach(async () => {
await unmountApp();
globalThis.fetch = originalFetch;
globalThis.EventSource = originalEventSource;
if (originalMatchMedia) window.matchMedia = originalMatchMedia;
storageModule?.resetStorageBackend();
memory.clear();
});

test("announces once, then never again in this browser", async () => {
memory.set(LOOK_AND_FEEL_KEY, "true");
await mountApp(PLAN);

const dialog = document.querySelector(DIALOG);
expect(dialog).not.toBeNull();
expect(memory.has(ANNOUNCEMENT_KEY)).toBe(false);

const gotIt = Array.from(document.querySelectorAll<HTMLButtonElement>(`${DIALOG} button`))
.find((button) => button.textContent?.trim() === "Got it");
if (!gotIt) throw new Error("Dismiss action did not render");
await act(async () => gotIt.click());

expect(document.querySelector(DIALOG)).toBeNull();
expect(memory.get(ANNOUNCEMENT_KEY)).toBe("1");

await unmountApp();
await mountApp(PLAN);
expect(document.querySelector(DIALOG)).toBeNull();
});

test("defers behind the look-and-feel chooser without spending the announcement", async () => {
// No look-and-feel marker: that chooser owns this session.
await mountApp(PLAN);

expect(document.querySelector('[aria-labelledby="plan-look-choice-title"]')).not.toBeNull();
expect(document.querySelector(DIALOG)).toBeNull();
expect(memory.has(ANNOUNCEMENT_KEY)).toBe(false);
});

test("stays out of a read-only archive session and keeps the announcement for later", async () => {
memory.set(LOOK_AND_FEEL_KEY, "true");
await mountApp({
...PLAN,
mode: "archive",
archivePlans: [{
filename: "saved.md",
status: "approved",
timestamp: "2026-07-31T00:00:00.000Z",
title: "Session document",
}],
});

expect(document.querySelector(DIALOG)).toBeNull();
expect(memory.has(ANNOUNCEMENT_KEY)).toBe(false);
});

test("stays out of the compact touch shell and keeps the announcement for later", async () => {
memory.set(LOOK_AND_FEEL_KEY, "true");
useCompactTouchMedia();
await mountApp(PLAN);

expect(document.querySelector(DIALOG)).toBeNull();
expect(memory.has(ANNOUNCEMENT_KEY)).toBe(false);
});
});
Loading