diff --git a/.pi/workflows/debug-weird-ui-behavior-with-cdp-logging/SKILL.md b/.pi/workflows/debug-weird-ui-behavior-with-cdp-logging/SKILL.md new file mode 100644 index 000000000..82a264b44 --- /dev/null +++ b/.pi/workflows/debug-weird-ui-behavior-with-cdp-logging/SKILL.md @@ -0,0 +1,120 @@ +--- +name: "Debug weird UI behavior with CDP logging" +description: "Use when weird app/UI behavior needs live reproduction and tracing, including flicker, stale state, remounts, focus/scroll jumps, action glitches, or ordering flashes" +--- + +# Debug weird UI behavior with CDP logging + +Use this when the app does something weird and the behavior must be observed live: refreshes, remounts, flickers, rows briefly unfolding, wrong ordering for a moment, state changing then reverting, actions firing twice, stale UI, unexpected navigation, focus loss, scroll jumps, or anything where code inspection alone is likely to lie. + +## Rules + +- Reproduce in the running dev app. Do not guess from code only. +- Use CDP against Electron (`CDP_PORT=39217`) when possible. +- Instrument the page with a small in-page logger before performing the action. +- Only act on disposable data. + - Create temp branches/worktrees/projects/sessions when needed. + - Use obvious names like `cdp-debug-*` or `tmp-*`. + - Delete/clean them up after the test. + - Do not use important branches, real user sessions, or serious project data for destructive actions. +- If a destructive action is required, make the temporary object first, verify it appears, then delete that object only. + +## CDP setup + +List targets: + +```bash +CDP_PORT=39217 /home/igorw/.pi/agent/skills/chrome-cdp/scripts/cdp.mjs list +``` + +Use the target id prefix from `list` in later commands. + +## Install a DOM mutation logger + +Adapt selectors to the surface being debugged. For sidebar branch/worktree flicker, this logger captures row labels, expanded state, and scroll position: + +```bash +CDP_PORT=39217 /home/igorw/.pi/agent/skills/chrome-cdp/scripts/cdp.mjs eval '(() => { + window.__uiDebug?.observer?.disconnect?.() + const root = document.querySelector(".sidebar-project-work-section") + window.__uiDebug = { events: [], startedAt: Date.now() } + const labels = () => Array.from(document.querySelectorAll(".sidebar-project-work-branch-toggle")) + .map((el) => ({ + text: el.textContent.trim(), + expanded: el.getAttribute("aria-expanded"), + disabled: el.hasAttribute("disabled"), + })) + const snap = (reason) => window.__uiDebug.events.push({ + t: Date.now(), + dt: Date.now() - window.__uiDebug.startedAt, + reason, + labels: labels(), + scrollTop: document.querySelector(".sidebar-project-work-scroll-shell")?.scrollTop ?? null, + }) + snap("install") + const observer = new MutationObserver((mutations) => { + if (!mutations.some((m) => m.type === "childList" || m.type === "attributes")) return + snap("mutation:" + mutations.map((m) => + m.type + (m.attributeName ? ":" + m.attributeName : "") + ":+" + m.addedNodes.length + ":-" + m.removedNodes.length + ).slice(0, 8).join(",")) + }) + if (root) observer.observe(root, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["aria-expanded", "data-current", "data-divider-after", "data-divider-before"], + }) + window.__uiDebug.observer = observer + return labels().slice(0, 20) +})()' +``` + +## Reproduce with temp data + +Example pattern: + +1. Create a temp thing (`cdp-debug-*`). +2. Confirm it appears. +3. Add a manual marker before the suspect action: + +```bash +CDP_PORT=39217 /home/igorw/.pi/agent/skills/chrome-cdp/scripts/cdp.mjs eval '(() => { + window.__uiDebug.events.push({ reason: "before-action", t: Date.now(), dt: Date.now() - window.__uiDebug.startedAt }) + return true +})()' +``` + +4. Perform the suspect action. +5. Dump recent events: + +```bash +CDP_PORT=39217 /home/igorw/.pi/agent/skills/chrome-cdp/scripts/cdp.mjs eval '(() => window.__uiDebug?.events?.slice(-80))()' +``` + +## What to look for + +- Full page reload: + - CDP state disappears, `window.__uiDebug` is gone, or Vite logs full reload. + - Check dev server watcher config, especially generated folders/worktrees. +- Real component/state flicker: + - Logger persists and shows multiple DOM mutations. + - Compare snapshots before/after each mutation. + - Look for stale mixed sources of truth: cache patched before related query/state refreshed, or vice versa. +- Remount/state reset: + - Local UI state resets while no page reload happened. + - Check keys, conditional rendering, parent state sync effects, and broad query writes. + +## Fix strategy + +- Prefer one consistent state transition over optimistic patch + stale secondary state. +- If UI derives from two sources, update them in an order that avoids impossible intermediate states. +- Preserve object references on broad cache merges when data is unchanged. +- Avoid full-shell/query refreshes for localized mutations. +- Add a regression test for the specific ordering/reference behavior when practical. + +## Cleanup + +- Remove temp branches/worktrees/projects/sessions created for the test. +- Re-run the logger after the fix with a fresh temp object. +- Keep unrelated local changes unstaged. +- Run the repo verification command when code changed. diff --git a/README.md b/README.md index 72a07baf3..da7aa43d8 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,7 @@ # There are many desktop apps for coding with AI, but this one... -image - +image
I've clanked a LOT. And during that clanking, I used a few apps. None of them really fit how I work. This one does. It's opinionated, focussed on UX and aimed at YOLOing with agents. diff --git a/branches/post-worktree-fixes-automations/joschasfeedback.md b/branches/post-worktree-fixes-automations/joschasfeedback.md deleted file mode 100644 index b3e994619..000000000 --- a/branches/post-worktree-fixes-automations/joschasfeedback.md +++ /dev/null @@ -1 +0,0 @@ -Addressed JosXa's Howcode feedback across composer layering, tool-call readability, sidebar/project onboarding, and embedded terminal clarity: composer popovers now use a named z-layer ladder, native edit/apply_patch calls preserve and render Pi-like diffs, Unassigned/new-session project sidebar affordances are clearer, terminal sessions advertise a safer embedded xterm.js capability baseline, and project creation now separates typed paths, GitHub URIs, “create in this folder”, and “use current folder as project”. Added regression coverage for preserved tool details, terminal env capabilities, and safely adding an already-git-initialized project when the global git-init toggle is enabled. diff --git a/branches/post-worktree-fixes-automations/settings_view.md b/branches/post-worktree-fixes-automations/settings_view.md deleted file mode 100644 index 82f99e43d..000000000 --- a/branches/post-worktree-fixes-automations/settings_view.md +++ /dev/null @@ -1 +0,0 @@ -Reorganized Settings into clearer user-facing categories, grouped all Pi-backed settings under a single Pi section with lighter category dividers, fixed Biome so checks work correctly from nested worktrees, and added this worktree-summary workflow so completed worktrees can be reviewed together before merging. diff --git a/branches/post-worktree-fixes-automations/ui-stuff.md b/branches/post-worktree-fixes-automations/ui-stuff.md deleted file mode 100644 index f90491d2a..000000000 --- a/branches/post-worktree-fixes-automations/ui-stuff.md +++ /dev/null @@ -1 +0,0 @@ -Reworked the project-work sidebar so worktrees fold with their owning branch without visually indenting or duplicating simple worktree rows, simplified sidebar fold/unfold controls into the composer/sidebar-footer surfaces instead of bespoke floating placement, restored landing/about access to the sidebar toggle, fixed thinking headers so long reasoning summaries wrap inside the thread viewport, and added persistent worktree completion state with inline actions for marking worktrees complete plus bulk merge/remove controls for completed worktrees from the root branch row. diff --git a/bun.lock b/bun.lock index 260269df4..c24482599 100644 --- a/bun.lock +++ b/bun.lock @@ -9,10 +9,10 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@earendil-works/pi-agent-core": "0.75.3", - "@earendil-works/pi-ai": "0.75.3", - "@earendil-works/pi-coding-agent": "0.75.3", - "@earendil-works/pi-tui": "0.75.3", + "@earendil-works/pi-agent-core": "0.76.0", + "@earendil-works/pi-ai": "0.76.0", + "@earendil-works/pi-coding-agent": "0.76.0", + "@earendil-works/pi-tui": "0.76.0", "@fontsource-variable/inter": "^5.2.8", "@mdxeditor/editor": "^4.0.0", "@pierre/diffs": "^1.2.2", @@ -243,13 +243,13 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.75.3", "", { "dependencies": { "@earendil-works/pi-ai": "^0.75.3", "ignore": "^7.0.5", "typebox": "^1.1.24", "yaml": "^2.8.2" } }, "sha512-azg09GSrckQa3ffbH09YEZC7DyHgmNSX+vmWEoEhQvp4icbzqbqLfIeMayMNEK/aGusm1SghZC4bPlDdagDALg=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.76.0", "", { "dependencies": { "@earendil-works/pi-ai": "^0.76.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-gNfR+ScnyAKr/13K8129svRBwO48jIK4jBHZRLaj3l4inKRkCRyCGs6hZnyQCYeiVNbBt1Jcjvb7osss44Q9xw=="], - "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.75.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.91.1", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "^2.2.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "openai": "6.26.0", "partial-json": "^0.1.7", "typebox": "^1.1.24" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-UKccS+ADlkSVJ49a00346jUfXmUi6zzzB+pPWotsyA6SxhKr2ejjkGQksGyR1DyNVrsEP/WWlsOSTUUwVlzNaA=="], + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.76.0", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-RO/L80iTxkK/nM2s5IVuirjH04UBsVzFfAT0qVcP/VwtDe9pjpqL+BVqb3XTyfitTD9tBo/hQuKmagN8Ep5qZA=="], - "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.75.3", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.75.3", "@earendil-works/pi-ai": "^0.75.3", "@earendil-works/pi-tui": "^0.75.3", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "cross-spawn": "^7.0.6", "diff": "^8.0.2", "glob": "^13.0.1", "highlight.js": "^10.7.3", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "jiti": "^2.7.0", "minimatch": "^10.2.3", "proper-lockfile": "^4.1.2", "typebox": "^1.1.24", "undici": "^8.3.0", "yaml": "^2.8.2" }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.6" }, "bin": { "pi": "dist/cli.js" } }, "sha512-LIi5/CdUBfcLp3BAtpLx1BfnHDLmDOQVdzYfS1H9fjjCw2dcPr9voSI5ncrhvZdgyFSnfHck4BCbNcfZk+TEHQ=="], + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.76.0", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.76.0", "@earendil-works/pi-ai": "^0.76.0", "@earendil-works/pi-tui": "^0.76.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.6" }, "bin": { "pi": "dist/cli.js" } }, "sha512-vTnKbgw1rDQq2MD+vcWgyYvEPMV4mMb07tZZMS7FOvlS5t/jzIX9smC+k97YGpfl8dT+L9Uj+MYyvYSBXVaEHw=="], - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.75.3", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "marked": "^15.0.12" }, "optionalDependencies": { "koffi": "^2.9.0" } }, "sha512-UbhtCsae+b3Y8/ZxtBPhiOrkD66gOHvJbfvLZwhBBsNtQuvUkZY5t9MQwmb8QcDYkFRnXHaq3FcEy1hjRSfj6w=="], + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.76.0", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "15.0.12" } }, "sha512-TWQEWqc38gVRYr/VTrlfePQPpJk938gNNLL1xuv0M+9cTkVr880/Q3baZQrxKoLrmN/MmVx7TyR8knYZGxTqqg=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], @@ -337,7 +337,7 @@ "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], - "@google/genai": ["@google/genai@1.50.1", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ=="], + "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], @@ -463,7 +463,7 @@ "@mdxeditor/gurx": ["@mdxeditor/gurx@1.2.4", "", { "peerDependencies": { "react": ">= 18 || >= 19", "react-dom": ">= 18 || >= 19" } }, "sha512-9ZykIFYhKaXaaSPCs1cuI+FvYDegJjbKwmA4ASE/zY+hJY6EYqvoye4esiO85CjhOw9aoD/izD/CU78/egVqmg=="], - "@mistralai/mistralai": ["@mistralai/mistralai@2.2.0", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-JQUGIXjFWnw/J9LpTSf/ZXwVW3Sh8FBAcfTo5QvAHqkl4CfSiIwnjRJhMoAFcP6ncCe84YPU1ncDGX+p3OXnfg=="], + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], @@ -705,7 +705,7 @@ "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.14", "", { "dependencies": { "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.6.1", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], "@smithy/property-provider": ["@smithy/property-provider@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ=="], @@ -1241,7 +1241,7 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -1299,7 +1299,7 @@ "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], - "hosted-git-info": ["hosted-git-info@9.0.2", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg=="], + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -1391,8 +1391,6 @@ "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], - "koffi": ["koffi@2.16.1", "", {}, "sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ=="], - "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "lexical": ["lexical@0.35.0", "", {}, "sha512-3VuV8xXhh5xJA6tzvfDvE0YBCMkIZUmxtRilJQDDdCgJCc+eut6qAv2qbN+pbqvarqcQqPN1UF+8YvsjmyOZpw=="], @@ -2061,11 +2059,13 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@earendil-works/pi-agent-core/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "@aws-sdk/client-bedrock-runtime/@smithy/node-http-handler": ["@smithy/node-http-handler@4.6.1", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg=="], - "@earendil-works/pi-coding-agent/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.6.1", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg=="], - "@earendil-works/pi-coding-agent/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.6.1", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg=="], + + "@earendil-works/pi-coding-agent/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], @@ -2109,6 +2109,12 @@ "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "@smithy/node-http-handler/@smithy/core": ["@smithy/core@3.24.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA=="], + + "@smithy/node-http-handler/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + + "@smithy/util-stream/@smithy/node-http-handler": ["@smithy/node-http-handler@4.6.1", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -2293,6 +2299,8 @@ "cacache/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "cli-truncate/string-width/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -2325,12 +2333,18 @@ "minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "ora/string-width/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "ora/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], @@ -2357,8 +2371,12 @@ "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "log-update/slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "log-update/wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "ora/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], diff --git a/desktop/app-settings/parsers.ts b/desktop/app-settings/parsers.ts index 5f04c5667..cfd0785c7 100644 --- a/desktop/app-settings/parsers.ts +++ b/desktop/app-settings/parsers.ts @@ -161,7 +161,6 @@ export function parseGitDiffBaselineDefaultPreference( const baseline = parsed as { kind?: unknown } return baseline.kind === 'head' || baseline.kind === 'previous' || - baseline.kind === 'yesterday' || baseline.kind === 'main-branch' || baseline.kind === 'dev-branch' ? ({ kind: baseline.kind } as ProjectDiffDefaultBaseline) diff --git a/desktop/app-settings/readers.ts b/desktop/app-settings/readers.ts index cf4ca06a5..e31787daa 100644 --- a/desktop/app-settings/readers.ts +++ b/desktop/app-settings/readers.ts @@ -139,7 +139,7 @@ export function loadAppSettings(): AppSettings { gitOpsDefaultMode: parseGitOpsModePreference(value(gitOpsDefaultModeKey)) ?? 'commit', gitDiffBaselineDefault: parseGitDiffBaselineDefaultPreference( value(gitDiffBaselineDefaultKey), - ) ?? { kind: 'head' }, + ) ?? { kind: 'main-branch' }, gitDiffRenderModeDefault: parseGitDiffRenderModePreference(value(gitDiffRenderModeDefaultKey)) ?? 'stacked', gitDiffFileTreeDefaultVisible: diff --git a/desktop/pi-threads/pi-settings-actions.ts b/desktop/pi-threads/pi-settings-actions.ts index 5bba6ea39..91c7b9b1d 100644 --- a/desktop/pi-threads/pi-settings-actions.ts +++ b/desktop/pi-threads/pi-settings-actions.ts @@ -1,6 +1,6 @@ import type { DesktopAction } from '../../shared/desktop-actions.ts' import type { AnyDesktopActionPayload } from '../../shared/desktop-contracts.ts' -import { type PiSettingsKey, updatePiSetting } from '../pi-settings.ts' +import { loadPiThemeState, type PiSettingsKey, updatePiSetting } from '../pi-settings.ts' import { type ActionHandlerResult, handledAction, unhandledAction } from './action-router-result.ts' const piSettingsKeys = new Set([ @@ -47,5 +47,10 @@ export async function handlePiSettingsDesktopAction( } const piSettings = await updatePiSetting(key, payload.value) - return handledAction({ piSettings }) + if (key !== 'theme') { + return handledAction({ piSettings }) + } + + const piTheme = await loadPiThemeState() + return handledAction({ piSettings, piTheme }) } diff --git a/desktop/pi-threads/workspace-actions.ts b/desktop/pi-threads/workspace-actions.ts index 397619853..f83e39200 100644 --- a/desktop/pi-threads/workspace-actions.ts +++ b/desktop/pi-threads/workspace-actions.ts @@ -10,6 +10,7 @@ import { getGitPreview, getGitPush, getGitRepoUrl, + getParentBranchName, getProjectDiffBaselinePreference, getProjectDiffRenderModePreference, getProjectId, @@ -39,7 +40,7 @@ import { ensureProject, getProjectWorktreeDirectory, hasRunningProjectThread, - listBranchThreadIds, + listProjectFamilyBranchThreadIds, listProjectSessionPaths, listProjectThreadIds, setProjectGitOpsMode, @@ -82,6 +83,12 @@ async function deletePersistedThreadsForWorkspace(threadIds: string[]) { } } +function isMissingBranchPruneError(result: Awaited>) { + if (!('error' in result)) return false + const normalizedError = String(result.error).toLowerCase() + return normalizedError.includes('branch') && normalizedError.includes('not found') +} + async function handleCommitWorkspaceAction(payload: AnyDesktopActionPayload) { const projectId = getProjectId(payload) if (!projectId) return handledAction() @@ -151,6 +158,7 @@ function handleDiffPreferencesWorkspaceAction(payload: AnyDesktopActionPayload) async function handleCreateWorktreeWorkspaceAction(payload: AnyDesktopActionPayload) { const projectId = getProjectId(payload) const branchName = getBranchName(payload) + const parentBranchName = getParentBranchName(payload) if (!(projectId && branchName)) return handledAction({ error: 'Branch name is required.' }) const rootProjectId = await getMainWorktreePath(projectId) @@ -172,12 +180,14 @@ async function handleCreateWorktreeWorkspaceAction(payload: AnyDesktopActionPayl cwd: result.projectId, rootCwd: result.rootProjectId, branchName: result.branchName, + parentBranchName, isMain: false, source: 'howcode', }) return handledAction({ didMutate: true, + branchName: result.branchName, projectId: result.projectId, rootProjectId: result.rootProjectId, }) @@ -388,13 +398,14 @@ async function handlePruneBranchWorkspaceAction(payload: AnyDesktopActionPayload }) } } - const threadIds = listBranchThreadIds(projectId, branchName) + const threadIds = listProjectFamilyBranchThreadIds(projectId, branchName) const result = await pruneProjectBranch(projectId, branchName) didMutate = didMutate || result.didMutate === true - if (!('error' in result)) { + if (!('error' in result) || isMissingBranchPruneError(result)) { const cleanupError = await deletePersistedThreadsForWorkspace(threadIds) if (cleanupError) return handledAction({ ...cleanupError, didMutate: true }) } + if (isMissingBranchPruneError(result)) return handledAction({ didMutate: true }) return handledAction('error' in result ? { ...result, didMutate } : result) } diff --git a/desktop/project-git/branch-actions.ts b/desktop/project-git/branch-actions.ts index a003a8420..53477fef6 100644 --- a/desktop/project-git/branch-actions.ts +++ b/desktop/project-git/branch-actions.ts @@ -1,3 +1,4 @@ +import { normalizeGitBranchName } from './branch-name.ts' import { createBranchSwitchPlan } from './branch-switch-plan.ts' import { formatGitCommandError, getNonInteractiveGitEnv, runGitWithOptions } from './git-runner.ts' @@ -80,7 +81,7 @@ function chooseBranchAfterPrune(branches: Set, branchName: string) { } export async function switchProjectBranch(projectId: string, branchName: string) { - const normalizedBranchName = branchName.trim() + const normalizedBranchName = normalizeGitBranchName(branchName) if (!normalizedBranchName) return { error: 'Branch name is required.' } try { @@ -107,7 +108,7 @@ export async function switchProjectBranch(projectId: string, branchName: string) timeout: 10_000, maxBuffer: 1024 * 1024, }) - return { didMutate: true } + return { didMutate: true, branchName: normalizedBranchName } } catch (error) { return { ...((await hasMergeInProgress(projectId)) ? { didMutate: true } : {}), diff --git a/desktop/project-git/branch-name.test.ts b/desktop/project-git/branch-name.test.ts new file mode 100644 index 000000000..0f6d6f310 --- /dev/null +++ b/desktop/project-git/branch-name.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { normalizeGitBranchName } from './branch-name.ts' + +describe('normalizeGitBranchName', () => { + it('keeps valid hierarchical branch names', () => { + expect(normalizeGitBranchName(' feature/sidebar-polish ')).toBe('feature/sidebar-polish') + }) + + it('turns common invalid branch names into nearby valid names', () => { + expect(normalizeGitBranchName('fix sidebar spacing')).toBe('fix-sidebar-spacing') + expect(normalizeGitBranchName('feature: sidebar? polish*')).toBe('feature-sidebar-polish') + expect(normalizeGitBranchName('foo..bar')).toBe('foo-bar') + expect(normalizeGitBranchName('.foo/bar.lock')).toBe('foo/bar') + expect(normalizeGitBranchName('HEAD')).toBe('branch') + }) +}) diff --git a/desktop/project-git/branch-name.ts b/desktop/project-git/branch-name.ts new file mode 100644 index 000000000..d30a7b397 --- /dev/null +++ b/desktop/project-git/branch-name.ts @@ -0,0 +1,43 @@ +const invalidBranchNameCharacters = new Set(['~', '^', ':', '?', '*', '[', '\\']) +const repeatedDashPattern = /-+/g +const repeatedSlashPattern = /\/+/g +const lockSuffixPattern = /\.lock$/i +const repeatedDotPattern = /\.\.+/g +const leadingDotPattern = /^\.+/ +const trailingDotPattern = /\.+$/ +const edgeDashPattern = /^-|-$/g + +function normalizeBranchSegment(segment: string) { + return segment + .split('') + .map((character) => + character.charCodeAt(0) <= 32 || + character.charCodeAt(0) === 127 || + invalidBranchNameCharacters.has(character) + ? '-' + : character, + ) + .join('') + .replaceAll(repeatedDotPattern, '-') + .replace(lockSuffixPattern, '') + .replace(leadingDotPattern, '') + .replace(trailingDotPattern, '') + .replaceAll(repeatedDashPattern, '-') + .replace(edgeDashPattern, '') +} + +export function normalizeGitBranchName(input: string) { + const normalized = input + .trim() + .replaceAll('@{', '-') + .replaceAll(repeatedSlashPattern, '/') + .split('/') + .map(normalizeBranchSegment) + .filter((segment) => segment.length > 0) + .join('/') + .replace(trailingDotPattern, '') + + if (!normalized || normalized === 'HEAD') return 'branch' + if (normalized.startsWith('-')) return `branch${normalized}` + return normalized +} diff --git a/desktop/project-git/project-diff-baselines.ts b/desktop/project-git/project-diff-baselines.ts index c1704e0ad..506b50bb4 100644 --- a/desktop/project-git/project-diff-baselines.ts +++ b/desktop/project-git/project-diff-baselines.ts @@ -8,35 +8,19 @@ import { getProjectCommitEntry, resolveCommitRevision } from './project-commits. import { isGitRepository } from './project-state.ts' import { captureWorktreeTree, EMPTY_TREE_OID } from './worktree-snapshot.ts' +const remoteHeadRefPattern = /^refs\/remotes\/(?:upstream|origin)\/(.+)$/ +const lsRemoteHeadRefPattern = /^ref:\s+refs\/heads\/(.+)\s+HEAD$/m + function getLastOpenedBaselineRef(projectId: string, capturedAt: string) { const projectHash = createHash('sha1').update(projectId).digest('hex') const baselineHash = createHash('sha1').update(`${projectId}:${capturedAt}`).digest('hex') return `refs/howcode/diff-baselines/${projectHash}/${baselineHash}` } -function formatLocalMidnightGitTimestamp(date = new Date()) { - const localMidnight = new Date(date) - localMidnight.setHours(0, 0, 0, 0) - - const year = localMidnight.getFullYear() - const month = `${localMidnight.getMonth() + 1}`.padStart(2, '0') - const day = `${localMidnight.getDate()}`.padStart(2, '0') - const hours = `${localMidnight.getHours()}`.padStart(2, '0') - const minutes = `${localMidnight.getMinutes()}`.padStart(2, '0') - const seconds = `${localMidnight.getSeconds()}`.padStart(2, '0') - const timezoneOffsetMinutes = -localMidnight.getTimezoneOffset() - const offsetSign = timezoneOffsetMinutes >= 0 ? '+' : '-' - const absoluteOffsetMinutes = Math.abs(timezoneOffsetMinutes) - const offsetHours = `${Math.floor(absoluteOffsetMinutes / 60)}`.padStart(2, '0') - const offsetMinutes = `${absoluteOffsetMinutes % 60}`.padStart(2, '0') - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${offsetSign}${offsetHours}${offsetMinutes}` -} - function toResolvedCommitBaseline( kind: Extract< ProjectDiffBaseline['kind'], - 'head' | 'previous' | 'yesterday' | 'main-branch' | 'dev-branch' | 'commit' + 'head' | 'previous' | 'main-branch' | 'dev-branch' | 'parent-branch' | 'branch' | 'commit' >, entry: Awaited>, ): ProjectDiffResolvedBaseline { @@ -63,6 +47,76 @@ async function resolveFirstExistingRef(projectId: string, candidateRefs: string[ return null } +function getBranchNameFromRemoteHeadRef(ref: string) { + const match = ref.trim().match(remoteHeadRefPattern) + const branchName = match?.[1]?.trim() + return branchName && branchName !== 'HEAD' ? branchName : null +} + +function getBranchNameFromLsRemoteHead(output: string) { + const match = output.match(lsRemoteHeadRefPattern) + const branchName = match?.[1]?.trim() + return branchName && branchName !== 'HEAD' ? branchName : null +} + +async function getRemoteDefaultBranchName(projectId: string) { + for (const remote of ['upstream', 'origin']) { + try { + const { stdout } = await runGitWithOptions( + projectId, + ['symbolic-ref', `refs/remotes/${remote}/HEAD`], + { + timeout: 10_000, + maxBuffer: 1024 * 128, + }, + ) + const branchName = getBranchNameFromRemoteHeadRef(stdout) + if (branchName) return { remote, branchName } + } catch { + // Some repos do not have remote HEAD refs locally. Fall back below. + } + } + + for (const remote of ['upstream', 'origin']) { + try { + const { stdout } = await runGitWithOptions( + projectId, + ['ls-remote', '--symref', remote, 'HEAD'], + { + timeout: 10_000, + maxBuffer: 1024 * 128, + }, + ) + const branchName = getBranchNameFromLsRemoteHead(stdout) + if (branchName) return { remote, branchName } + } catch { + // Offline repos can still use local main/master fallbacks below. + } + } + + return null +} + +async function getDefaultBranchCandidates(projectId: string) { + const remoteDefault = await getRemoteDefaultBranchName(projectId) + const symbolicCandidates = remoteDefault + ? [ + `refs/remotes/${remoteDefault.remote}/${remoteDefault.branchName}`, + `refs/heads/${remoteDefault.branchName}`, + ] + : [] + + return [ + ...symbolicCandidates, + 'refs/heads/main', + 'refs/remotes/origin/main', + 'refs/remotes/upstream/main', + 'refs/heads/master', + 'refs/remotes/origin/master', + 'refs/remotes/upstream/master', + ] +} + async function resolveMergeBaseRevision(projectId: string, targetRev: string) { if (!(await hasHeadCommit(projectId))) { return EMPTY_TREE_OID @@ -84,13 +138,17 @@ async function resolveMergeBaseRevision(projectId: string, targetRev: string) { async function resolveNamedBranchBaseline( projectId: string, options: { - kind: Extract + kind: Extract< + ProjectDiffBaseline['kind'], + 'main-branch' | 'dev-branch' | 'parent-branch' | 'branch' + > label: string candidateRefs: string[] }, ): Promise { const resolvedTarget = await resolveFirstExistingRef(projectId, options.candidateRefs) if (!resolvedTarget) { + if (options.kind === 'main-branch') return resolveHeadBaseline(projectId) throw new Error(`Could not find ${options.label.toLowerCase()}.`) } @@ -123,6 +181,46 @@ async function resolveNamedBranchBaseline( } } +async function resolveParentBranchBaseline( + projectId: string, + branchName: string, +): Promise { + const trimmedBranchName = branchName.trim() + if (trimmedBranchName.length === 0) { + throw new Error('Could not find parent branch.') + } + + return resolveNamedBranchBaseline(projectId, { + kind: 'parent-branch', + label: `Parent branch · ${trimmedBranchName}`, + candidateRefs: [ + `refs/heads/${trimmedBranchName}`, + `refs/remotes/origin/${trimmedBranchName}`, + `refs/remotes/upstream/${trimmedBranchName}`, + ], + }) +} + +async function resolveBranchBaseline( + projectId: string, + branchName: string, +): Promise { + const trimmedBranchName = branchName.trim() + if (trimmedBranchName.length === 0) { + throw new Error('Could not find branch.') + } + + return resolveNamedBranchBaseline(projectId, { + kind: 'branch', + label: `Branch · ${trimmedBranchName}`, + candidateRefs: [ + `refs/heads/${trimmedBranchName}`, + `refs/remotes/origin/${trimmedBranchName}`, + `refs/remotes/upstream/${trimmedBranchName}`, + ], + }) +} + async function resolveHeadBaseline(projectId: string): Promise { const entry = await getProjectCommitEntry(projectId, 'HEAD') if (!entry) { @@ -164,67 +262,11 @@ async function resolvePreviousCommitBaseline( } } -async function resolveYesterdayBaseline(projectId: string): Promise { - if (!(await hasHeadCommit(projectId))) { - return { - kind: 'yesterday', - rev: EMPTY_TREE_OID, - label: 'Initial state', - commitSha: null, - shortSha: null, - subject: null, - committedAt: null, - capturedAt: null, - } - } - - let stdout = '' - - try { - ;({ stdout } = await runGitWithOptions( - projectId, - ['rev-list', '-1', `--before=${formatLocalMidnightGitTimestamp()}`, 'HEAD'], - { - timeout: 10_000, - maxBuffer: 1024 * 128, - }, - )) - } catch { - stdout = '' - } - - const commitSha = stdout.trim() - if (commitSha.length === 0) { - return { - kind: 'yesterday', - rev: EMPTY_TREE_OID, - label: 'Initial state', - commitSha: null, - shortSha: null, - subject: null, - committedAt: null, - capturedAt: null, - } - } - - const entry = await getProjectCommitEntry(projectId, commitSha) - if (!entry) { - throw new Error('Could not resolve the commit for yesterday.') - } - - return toResolvedCommitBaseline('yesterday', entry) -} - async function resolveMainBranchBaseline(projectId: string): Promise { return resolveNamedBranchBaseline(projectId, { kind: 'main-branch', - label: 'Main branch', - candidateRefs: [ - 'refs/heads/main', - 'refs/remotes/origin/main', - 'refs/heads/master', - 'refs/remotes/origin/master', - ], + label: 'Default branch', + candidateRefs: await getDefaultBranchCandidates(projectId), }) } @@ -232,7 +274,7 @@ async function resolveDevBranchBaseline(projectId: string): Promise branch === 'main' || branch === 'master') ?? null +} + +async function getDevBranchName(projectId: string, branches: readonly string[]) { + if (branches.includes('dev')) return 'dev' + + for (const ref of ['refs/remotes/origin/dev', 'refs/remotes/upstream/dev']) { + try { + await runGitWithOptions(projectId, ['show-ref', '--verify', ref], { + timeout: 10_000, + maxBuffer: 1024 * 128, + }) + return 'dev' + } catch { + // Try the next remote. + } + } + + return null +} + +async function getMainBranchName(projectId: string, branches: readonly string[]) { + for (const branch of ['main', 'master']) { + if (branches.includes(branch)) return branch + + for (const ref of [`refs/remotes/origin/${branch}`, `refs/remotes/upstream/${branch}`]) { + try { + await runGitWithOptions(projectId, ['show-ref', '--verify', ref], { + timeout: 10_000, + maxBuffer: 1024 * 128, + }) + return branch + } catch { + // Try the next branch/ref. + } + } + } + + return null +} + async function getDiffStats(projectId: string) { try { const args = (await hasHeadCommit(projectId)) @@ -185,6 +251,9 @@ export async function loadProjectGitState(projectId: string): Promise []), ]) + const [defaultBranchName, devBranchName, mainBranchName] = await Promise.all([ + getDefaultBranchName(projectId, branches), + getDevBranchName(projectId, branches), + getMainBranchName(projectId, branches), + ]) + return { projectId, isGitRepo: true, branch, branches, + defaultBranchName, + devBranchName, + mainBranchName, fileCount: statusSummary.fileCount, stagedFileCount: statusSummary.stagedFileCount, unstagedFileCount: statusSummary.unstagedFileCount, diff --git a/desktop/project-git/worktrees.ts b/desktop/project-git/worktrees.ts index 2b6ec3c60..3398984eb 100644 --- a/desktop/project-git/worktrees.ts +++ b/desktop/project-git/worktrees.ts @@ -1,5 +1,6 @@ import { access } from 'node:fs/promises' import path from 'node:path' +import { normalizeGitBranchName } from './branch-name.ts' import { formatGitCommandError, getNonInteractiveGitEnv, runGitWithOptions } from './git-runner.ts' export type GitWorktreeEntry = { @@ -161,7 +162,7 @@ export async function createProjectWorktree(input: { branchName: string worktreeDirectory: string }): Promise { - const branchName = input.branchName.trim() + const branchName = normalizeGitBranchName(input.branchName) if (!branchName) return { error: 'Branch name is required.' } const folderName = sanitizeWorktreeFolderName(branchName) diff --git a/desktop/project-import.ts b/desktop/project-import.ts index b73a81762..f6a36a6c5 100644 --- a/desktop/project-import.ts +++ b/desktop/project-import.ts @@ -70,9 +70,17 @@ export async function importProjects(projectIds: string[]) { } setProjectImportState(true) + const importedProjectIds = candidates.map((candidate) => candidate.projectId) + const importedProjectIdSet = new Set(importedProjectIds) + const importedProjects = listProjects(getDesktopWorkingDirectory()).filter( + (project) => + importedProjectIdSet.has(project.id) || + (project.worktree?.rootProjectId && importedProjectIdSet.has(project.worktree.rootProjectId)), + ) return { - importedProjectIds: candidates.map((candidate) => candidate.projectId), + importedProjectIds, + importedProjects, checkedProjectCount: candidates.length, repoProjectCount, originProjectCount, diff --git a/desktop/thread-state-db-queries.test.ts b/desktop/thread-state-db-queries.test.ts new file mode 100644 index 000000000..7249987dc --- /dev/null +++ b/desktop/thread-state-db-queries.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +let userDataPath = '' + +async function loadThreadStateDb() { + return import('./thread-state-db.ts') +} + +describe('thread state db branch queries', () => { + beforeEach(() => { + userDataPath = mkdtempSync(path.join(os.tmpdir(), 'howcode-thread-db-')) + // biome-ignore lint/complexity/useLiteralKeys: ProcessEnv is an index-signature type. + process.env['HOWCODE_USER_DATA_PATH'] = userDataPath + }) + + afterEach(async () => { + const { closeThreadStateDatabaseForTests } = await import('./thread-state-db/db.ts') + closeThreadStateDatabaseForTests() + // biome-ignore lint/complexity/useLiteralKeys: ProcessEnv is an index-signature type. + delete process.env['HOWCODE_USER_DATA_PATH'] + if (userDataPath) rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('lists branch threads across a root project and its worktrees', async () => { + const { getThreadStateDatabase } = await import('./thread-state-db/db.ts') + const { listProjectFamilyBranchThreadIds } = await loadThreadStateDb() + const db = getThreadStateDatabase() + + db.prepare('INSERT INTO projects (cwd, name) VALUES (?, ?)').run('/repo', 'Repo') + db.prepare('INSERT INTO projects (cwd, name) VALUES (?, ?)').run( + '/repo/.worktrees/feature', + 'Feature', + ) + db.prepare( + `INSERT INTO project_worktrees (cwd, root_cwd, branch_name, is_main, source) + VALUES (?, ?, ?, ?, ?)`, + ).run('/repo/.worktrees/feature', '/repo', 'feature', 0, 'howcode') + const insertThread = db.prepare( + `INSERT INTO threads (id, cwd, session_path, title, last_modified_ms, branch_name) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + insertThread.run('root-thread', '/repo', '/sessions/root.jsonl', 'Root', 1, 'feature') + insertThread.run( + 'worktree-thread', + '/repo/.worktrees/feature', + '/sessions/worktree.jsonl', + 'Worktree', + 1, + 'feature', + ) + insertThread.run('other-branch', '/repo', '/sessions/other.jsonl', 'Other', 1, 'main') + + expect(listProjectFamilyBranchThreadIds('/repo', 'feature')).toEqual([ + 'root-thread', + 'worktree-thread', + ]) + }) +}) diff --git a/desktop/thread-state-db-worktree-writes.test.ts b/desktop/thread-state-db-worktree-writes.test.ts new file mode 100644 index 000000000..31bf06c54 --- /dev/null +++ b/desktop/thread-state-db-worktree-writes.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +let userDataPath = '' + +describe('thread state db worktree writes', () => { + beforeEach(() => { + userDataPath = mkdtempSync(path.join(os.tmpdir(), 'howcode-worktree-db-')) + // biome-ignore lint/complexity/useLiteralKeys: ProcessEnv is an index-signature type. + process.env['HOWCODE_USER_DATA_PATH'] = userDataPath + }) + + afterEach(async () => { + const { closeThreadStateDatabaseForTests } = await import('./thread-state-db/db.ts') + closeThreadStateDatabaseForTests() + // biome-ignore lint/complexity/useLiteralKeys: ProcessEnv is an index-signature type. + delete process.env['HOWCODE_USER_DATA_PATH'] + if (userDataPath) rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('preserves recorded parent branch metadata when a later worktree sync has no parent', async () => { + const { getThreadStateDatabase } = await import('./thread-state-db/db.ts') + const { upsertProjectWorktree } = await import('./thread-state-db/worktree-writes.ts') + const db = getThreadStateDatabase() + db.prepare('INSERT INTO projects (cwd, name) VALUES (?, ?)').run('/repo', 'Repo') + db.prepare('INSERT INTO projects (cwd, name) VALUES (?, ?)').run( + '/repo/.worktrees/feature', + 'Feature', + ) + + upsertProjectWorktree({ + cwd: '/repo/.worktrees/feature', + rootCwd: '/repo', + branchName: 'feature', + parentBranchName: 'main', + isMain: false, + source: 'howcode', + }) + upsertProjectWorktree({ + cwd: '/repo/.worktrees/feature', + rootCwd: '/repo', + branchName: 'feature', + isMain: false, + source: 'howcode', + }) + + const row = db + .prepare('SELECT parent_branch_name AS parentBranchName FROM project_worktrees WHERE cwd = ?') + .get('/repo/.worktrees/feature') as { parentBranchName: string | null } + expect(row.parentBranchName).toBe('main') + }) +}) diff --git a/desktop/thread-state-db.ts b/desktop/thread-state-db.ts index 3b9e6e306..d8afea9de 100644 --- a/desktop/thread-state-db.ts +++ b/desktop/thread-state-db.ts @@ -14,6 +14,7 @@ export { listBranchSessionPaths, listBranchThreadIds, listInboxThreads, + listProjectFamilyBranchThreadIds, listProjectFamilyProjectIds, listProjectFamilySessionPaths, listProjectSessionPaths, diff --git a/desktop/thread-state-db/mappers.ts b/desktop/thread-state-db/mappers.ts index c796815d1..3c898f3ea 100644 --- a/desktop/thread-state-db/mappers.ts +++ b/desktop/thread-state-db/mappers.ts @@ -71,6 +71,7 @@ export function mapProjectRow(row: ProjectRow): Project { ? { rootProjectId: row.worktreeRootProjectId, branchName: row.worktreeBranchName, + parentBranchName: row.worktreeParentBranchName, isMain: Boolean(row.worktreeIsMain), source: row.worktreeSource === 'imported' ? 'imported' : 'howcode', completed: Boolean(row.worktreeCompleted), diff --git a/desktop/thread-state-db/queries.ts b/desktop/thread-state-db/queries.ts index 422a25eab..d7c7298df 100644 --- a/desktop/thread-state-db/queries.ts +++ b/desktop/thread-state-db/queries.ts @@ -57,6 +57,7 @@ export function listProjects(cwd: string): Project[] { projects.git_ops_mode AS gitOpsMode, project_worktrees.root_cwd AS worktreeRootProjectId, project_worktrees.branch_name AS worktreeBranchName, + project_worktrees.parent_branch_name AS worktreeParentBranchName, project_worktrees.is_main AS worktreeIsMain, project_worktrees.source AS worktreeSource, project_worktrees.completed AS worktreeCompleted, @@ -86,6 +87,7 @@ export function listProjects(cwd: string): Project[] { projects.git_ops_mode, project_worktrees.root_cwd, project_worktrees.branch_name, + project_worktrees.parent_branch_name, project_worktrees.is_main, project_worktrees.source, project_worktrees.completed, @@ -147,6 +149,7 @@ function parseDiffBaseline(value: string | null): ProjectDiffBaseline | null { } const baseline = parsed as { + branchName?: unknown kind?: unknown rev?: unknown capturedAt?: unknown @@ -155,10 +158,13 @@ function parseDiffBaseline(value: string | null): ProjectDiffBaseline | null { switch (baseline.kind) { case 'head': case 'previous': - case 'yesterday': case 'main-branch': case 'dev-branch': return { kind: baseline.kind } + case 'parent-branch': + return parseNamedDiffBaseline('parent-branch', baseline.branchName) + case 'branch': + return parseNamedDiffBaseline('branch', baseline.branchName) case 'last-opened': return typeof baseline.rev === 'string' && baseline.rev.trim().length > 0 ? { @@ -181,6 +187,15 @@ function parseDiffBaseline(value: string | null): ProjectDiffBaseline | null { } } +function parseNamedDiffBaseline( + kind: Extract, + branchName: unknown, +): ProjectDiffBaseline | null { + return typeof branchName === 'string' && branchName.trim().length > 0 + ? { kind, branchName } + : null +} + export function getThreadDiffPreferences(sessionPath: string): ProjectDiffPreferences { const db = getThreadStateDatabase() const row = db @@ -434,6 +449,29 @@ export function listBranchThreadIds(projectId: string, branchName: string) { return rows.map((row) => row.id).filter((id): id is string => typeof id === 'string') } +export function listProjectFamilyBranchThreadIds(projectId: string, branchName: string) { + const db = getThreadStateDatabase() + const rows = db + .prepare( + ` + SELECT id AS id, session_path AS sessionPath + FROM threads + WHERE branch_name = ? + AND ( + cwd = ? + OR cwd IN ( + SELECT cwd + FROM project_worktrees + WHERE root_cwd = ? AND is_main = 0 + ) + ) + `, + ) + .all(branchName, projectId, projectId) as ThreadPathRow[] + + return rows.map((row) => row.id).filter((id): id is string => typeof id === 'string') +} + export function listProjectThreadIds(projectId: string) { const db = getThreadStateDatabase() const rows = db diff --git a/desktop/thread-state-db/schema.ts b/desktop/thread-state-db/schema.ts index 8748dd654..6626ff211 100644 --- a/desktop/thread-state-db/schema.ts +++ b/desktop/thread-state-db/schema.ts @@ -242,6 +242,7 @@ const threadStateSchemaSql = ` cwd TEXT PRIMARY KEY, root_cwd TEXT NOT NULL, branch_name TEXT, + parent_branch_name TEXT, is_main INTEGER NOT NULL DEFAULT 0, source TEXT NOT NULL DEFAULT 'howcode', completed INTEGER NOT NULL DEFAULT 0, @@ -309,6 +310,7 @@ function ensureProjectUsageTotalsColumns(database: Database) { function ensureProjectWorktreeColumns(database: Database) { addColumnIfMissing(database, 'project_worktrees', 'completed INTEGER NOT NULL DEFAULT 0') + addColumnIfMissing(database, 'project_worktrees', 'parent_branch_name TEXT') } function resetRunningThreads(database: Database) { diff --git a/desktop/thread-state-db/types.ts b/desktop/thread-state-db/types.ts index e33dd2a58..720351a22 100644 --- a/desktop/thread-state-db/types.ts +++ b/desktop/thread-state-db/types.ts @@ -19,6 +19,7 @@ export type ProjectRow = { gitOpsMode: string | null worktreeRootProjectId: string | null worktreeBranchName: string | null + worktreeParentBranchName: string | null worktreeIsMain: number | null worktreeSource: string | null worktreeCompleted: number | null diff --git a/desktop/thread-state-db/worktree-writes.ts b/desktop/thread-state-db/worktree-writes.ts index d827c6c6f..bf6e6303a 100644 --- a/desktop/thread-state-db/worktree-writes.ts +++ b/desktop/thread-state-db/worktree-writes.ts @@ -6,6 +6,7 @@ export type ProjectWorktreeMetadata = { cwd: string rootCwd: string branchName: string | null + parentBranchName?: string | null | undefined isMain: boolean source: ProjectWorktreeSource } @@ -43,11 +44,12 @@ export function upsertProjectWorktree(metadata: ProjectWorktreeMetadata) { const db = getThreadStateDatabase() db.prepare( ` - INSERT INTO project_worktrees (cwd, root_cwd, branch_name, is_main, source) - VALUES (?, ?, ?, ?, ?) + INSERT INTO project_worktrees (cwd, root_cwd, branch_name, parent_branch_name, is_main, source) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(cwd) DO UPDATE SET root_cwd = excluded.root_cwd, branch_name = excluded.branch_name, + parent_branch_name = COALESCE(excluded.parent_branch_name, project_worktrees.parent_branch_name), is_main = excluded.is_main, source = excluded.source, updated_at = CURRENT_TIMESTAMP @@ -56,6 +58,7 @@ export function upsertProjectWorktree(metadata: ProjectWorktreeMetadata) { metadata.cwd, metadata.rootCwd, metadata.branchName, + metadata.parentBranchName ?? null, metadata.isMain ? 1 : 0, metadata.source, ) diff --git a/docs/agent-hardening-one-shot-prompt.md b/docs/agent-hardening-one-shot-prompt.md deleted file mode 100644 index bc5e3e08a..000000000 --- a/docs/agent-hardening-one-shot-prompt.md +++ /dev/null @@ -1,201 +0,0 @@ -# One-shot prompt: execute the hardening + ARIA plan - -> Historical note: this prompt references several pre-split file paths. For current ownership, -> prefer `README.md` and `docs/lane-map.md` when they disagree with this prompt. - -```md -You are working in the `howcode` repo. - -Your mission is to execute the hardening and UI traversability plan in this repository in a careful, evidence-first way, using the local `agent-native-hardening` skill and the repo instructions. - -## First read these files completely - -1. `AGENTS.md` -2. `docs/agent-hardening-plan.md` -3. `/home/igorw/.pi/agent/skills/agent-native-hardening/SKILL.md` -4. `/home/igorw/.pi/agent/skills/agent-native-hardening/references/scoring-rubric.md` -5. `docs/lane-map.md` -6. `README.md` - -Also inspect the current relevant implementation files before changing anything: - -- `desktop/pi-desktop-runtime.cts` -- `desktop/pi-threads.cts` -- `desktop/thread-state-db.cts` -- `src/app/AppShell.tsx` -- `src/app/components/workspace/Composer.tsx` -- `src/app/components/sidebar/ProjectTree.tsx` -- `src/app/components/sidebar/Sidebar.tsx` -- `src/app/app-shell/AppShellWorkspace.tsx` -- `src/app/components/workspace/composer/ComposerGitOpsSurface.tsx` -- `src/app/components/workspace/TerminalPanel.tsx` -- `src/app/components/settings/ArchivedThreadsPanel.tsx` -- `src/app/state/workspace.ts` -- `src/app/state/workspace.test.ts` -- `shared/desktop-contracts.ts` -- `src/electron/main/index.ts` - -## Important operating rules - -- Follow `AGENTS.md` strictly. -- Treat `docs/agent-hardening-plan.md` as the source plan unless code reality proves otherwise. -- Use the `agent-native-hardening` skill workflow: baseline, evidence sweep, lane planning, implementation, merge/stabilize, final report. -- Use `explore_subagent` first for evidence gathering before major edits. -- Prefer worktree/lane style execution when it reduces overlap. -- Do not overwrite or absorb unrelated user changes if the git tree is dirty. -- If the working tree is dirty, preserve those edits and work around them safely. -- Use commit as the primary validation step for major changes, per repo guidance. -- Commit after every major change. -- Do not run the full check suite by default unless needed; run targeted validation relevant to the lane, then use commits as the main gate. -- Prefer focused `apply_patch` edits. - -## Primary outcomes - -You have two top-level objectives: - -1. execute the structural hardening plan so the biggest godfiles move toward single-purpose modules -2. improve UI accessibility and agent traversability so a CDP-driven agent can locate and operate the app using stable role/name/state queries instead of brittle CSS/text heuristics - -## Success criteria - -### Structural hardening - -- Reduce the largest mixed-responsibility files by extracting cohesive modules. -- Keep public contracts stable unless a contract change is clearly justified. -- Deduplicate shared title/message mapping logic. -- Move orchestration logic out of view-heavy files where appropriate. -- Prefer small focused modules with obvious ownership. - -### UI traversability / ARIA - -- Every important interactive control has a stable accessible name. -- Dialogs are real dialogs. -- Popup triggers expose machine-readable open/closed state. -- Expanded/selected/current state is encoded with ARIA where appropriate. -- Major regions have semantic landmarks and labels. -- Hover-only actions are still discoverable to keyboard/CDP agents. - -## Execute in this order unless evidence forces a better order - -### Lane A — shared desktop thread/message utilities - -Goal: - -- extract shared pure helpers for title normalization and message mapping -- make both `desktop/pi-desktop-runtime.cts` and `desktop/pi-threads.cts` consume them - -Expected outputs: - -- a shared helper module for thread/message mapping -- reduced duplication in both desktop runtime files -- minimal deterministic tests for extracted pure transforms if practical - -### Lane B — database layer split - -Goal: - -- split `desktop/thread-state-db.cts` by responsibility without breaking callers - -Expected outputs: - -- separate DB bootstrap/schema/query/write/mapping modules -- a thin public API surface or facade if needed - -### Lane C — runtime/composer split - -Goal: - -- split `desktop/pi-desktop-runtime.cts` into focused modules - -Expected outputs: - -- runtime registry module -- attachment processing module -- thread publishing/event module -- composer service module -- facade entry module that is much easier to read - -### Lane D — AppShell controller split - -Goal: - -- make `src/app/AppShell.tsx` mostly composition only, matching the lane map - -Expected outputs: - -- controller hook(s) for orchestration -- effect-specific hooks where helpful -- layout extraction if it materially improves traversability - -### Lane E — UI decomposition - -Goal: - -- split `Composer.tsx` and `ProjectTree.tsx` into smaller purpose-built pieces - -Expected outputs: - -- subcomponents and/or controller hooks for composer -- subcomponents and/or controller hooks for project tree - -### Lane F — ARIA and CDP traversability hardening - -Use the audit in `docs/agent-hardening-plan.md` and implement the highest-value fixes first. - -Priority fixes: - -1. `src/app/components/settings/ArchivedThreadsPanel.tsx` - - add proper dialog semantics: `role="dialog"`, `aria-modal`, `aria-labelledby` - - ensure focus moves into the dialog on open and is restored on close if feasible - - support Escape dismissal if appropriate - -2. Popup/menu semantics - - `src/app/components/sidebar/ProjectActionMenu.tsx` - - `src/app/components/sidebar/SettingsMenu.tsx` - - menu-like controls in `src/app/components/workspace/Composer.tsx` - - product/menu-like controls in `src/app/components/workspace/WorkspaceHeader.tsx` - - add `aria-haspopup`, `aria-expanded`, `aria-controls`, and menu roles where appropriate - -3. Project tree state semantics - - add `aria-expanded` and `aria-controls` on project expand/collapse controls - - expose selected/current state for active nav/project/thread items - - ensure row-level actions remain accessible when not hovered - -4. Landmark labeling - - label the sidebar - - label the threads section - - label the terminal panel or other major regions lacking labels - -## Validation strategy - -- Start by checking git status and assessing whether the tree is clean. -- If the tree is not clean, do not overwrite unrelated changes. -- For each major lane: - - inspect the exact files affected - - implement focused changes - - run the smallest relevant validation possible - - commit that lane as one atomic change -- Add or update deterministic tests only for extracted pure logic or reducers/helpers. -- If there are existing tests covering touched logic, run only those relevant tests unless the full suite becomes necessary. - -## Reporting requirements - -At the end, report in this format: - -1. Findings implemented, ordered by severity/value -2. Updated scorecard (`agent_native`, `fully_typed`, `traversable`, `test_coverage`, `feedback_loops`, `self_documenting`) with before/after values and file-grounded evidence -3. Exact files changed per lane -4. Commits created -5. Remaining risks -6. Next-step options - -## Additional guidance - -- Prefer in-code discoverability over extra docs. -- Add comments only for invariants, side effects, ownership boundaries, and non-obvious control flow. -- Keep changes mergeable and incremental. -- If one lane becomes too risky, narrow the scope and finish the highest-confidence slices first. -- If code reality conflicts with the plan, say so explicitly and revise based on evidence. - -Now execute the plan. -``` diff --git a/docs/agent-hardening-plan.md b/docs/agent-hardening-plan.md deleted file mode 100644 index c41e643cc..000000000 --- a/docs/agent-hardening-plan.md +++ /dev/null @@ -1,422 +0,0 @@ -# Agent hardening and UI traversability plan - -> Historical note: this plan predates the current shell split. Treat `README.md`, `docs/lane-map.md`, -> and live file paths as source of truth where this document references pre-split files like -> `WorkspaceHeader.tsx`. - -This plan captures the next hardening pass for the repo using the `agent-native-hardening` lens. - -## Status update - -Completed from this plan: - -- shared Pi message/title mapping now lives in `shared/pi-message-mapper.ts` -- `desktop/thread-state-db.cts` has been split into `desktop/thread-state-db/*` -- `desktop/pi-desktop-runtime.cts` is now a thin facade over `desktop/runtime/*` -- `desktop/pi-threads.cts` is now a thin facade over `desktop/pi-threads/*` -- `src/app/AppShell.tsx` is now a thin composition wrapper over `src/app/app-shell/*` -- `Composer.tsx` and `ProjectTree.tsx` have been split into focused subcomponents/controllers -- primary ARIA/traversability fixes landed for dialogs, menus, landmarks, and expanded/current state - -Remaining value from this doc is mainly the follow-up work around still-mock desktop actions, richer thread fidelity, and any additional accessibility polish. - -Primary goals: - -1. split godfiles into single-purpose modules -2. improve traversability for humans and agents -3. make UI controls easier to discover and operate via accessibility semantics and CDP - -## Baseline scorecard - -| Category | Score | Evidence | -| --- | ---: | --- | -| `agent_native` | 5/10 | good shared contracts and lane map, but several orchestration hubs own too many behaviors | -| `fully_typed` | 7/10 | strict TS is enabled in renderer and desktop runtime configs | -| `traversable` | 4/10 | large mixed-responsibility files and duplicated message-mapping logic increase reread cost | -| `test_coverage` | 3/10 | deterministic reducer tests exist, but extracted pure helpers are not broadly covered | -| `feedback_loops` | 8/10 | `check`, lint, typecheck, test, build, and pre-commit hooks are already wired | -| `self_documenting` | 6/10 | README and lane map exist, but some files no longer match the documented ownership model | - -## High-priority findings - -### P0: split first - -#### 1. `desktop/pi-desktop-runtime.cts` - -Current mixed responsibilities: - -- runtime module loading and caching -- runtime/session activation -- message/title mapping -- attachment file ingestion -- live thread publishing and desktop event emission -- composer actions - -Evidence: - -- `desktop/pi-desktop-runtime.cts:43-55` -- `desktop/pi-desktop-runtime.cts:182-218` -- `desktop/pi-desktop-runtime.cts:220-316` -- `desktop/pi-desktop-runtime.cts:340-407` -- `desktop/pi-desktop-runtime.cts:430-513` - -Target split: - -- `desktop/runtime/runtime-registry.cts` -- `desktop/runtime/message-mappers.cts` -- `desktop/runtime/attachments.cts` -- `desktop/runtime/thread-publisher.cts` -- `desktop/runtime/composer-service.cts` - -Definition of done: - -- `pi-desktop-runtime.cts` becomes a thin facade/orchestrator -- message mapping exists in one shared place only -- publish flow and composer flow are independently readable - -#### 2. `src/app/AppShell.tsx` - -Current mixed responsibilities: - -- reducer wiring and derived state -- async bootstrap and reload effects -- desktop event subscription -- action post-processing and side effects -- top-level layout composition - -Evidence: - -- `src/app/AppShell.tsx:25-77` -- `src/app/AppShell.tsx:78-148` -- `src/app/AppShell.tsx:150-183` -- `src/app/AppShell.tsx:185-245` -- `src/app/AppShell.tsx:275-369` - -This conflicts with `docs/lane-map.md:7-9`, which says `AppShell` should be top-level composition only. - -Target split: - -- `src/app/app-shell/useAppShellController.ts` -- `src/app/app-shell/useDesktopEventSync.ts` -- `src/app/app-shell/useComposerSync.ts` -- `src/app/app-shell/AppShellLayout.tsx` - -Definition of done: - -- `AppShell.tsx` mostly wires a controller to a layout -- async effect clusters are moved into focused hooks -- action routing is readable without scanning render markup - -#### 3. `desktop/thread-state-db.cts` - -Current mixed responsibilities: - -- database path/bootstrap/schema ownership -- row-to-domain mapping -- relative-age formatting -- read queries -- write commands and mutations - -Evidence: - -- `desktop/thread-state-db.cts:62-92` -- `desktop/thread-state-db.cts:94-138` -- `desktop/thread-state-db.cts:140-148` -- `desktop/thread-state-db.cts:150-310` -- `desktop/thread-state-db.cts:312-440` - -Target split: - -- `desktop/thread-state-db/db.cts` -- `desktop/thread-state-db/schema.cts` -- `desktop/thread-state-db/queries.cts` -- `desktop/thread-state-db/writes.cts` -- `desktop/thread-state-db/mappers.cts` - -Definition of done: - -- schema/bootstrap code is isolated -- queries and writes are no longer interleaved -- presentation formatting is not mixed into storage code unless intentionally shared - -### P1: split next - -#### 4. `desktop/pi-threads.cts` - -Current mixed responsibilities: - -- shell loading -- session discovery and indexing -- cached/live thread hydration -- desktop action routing - -Evidence: - -- `desktop/pi-threads.cts:168-223` -- `desktop/pi-threads.cts:234-276` -- `desktop/pi-threads.cts:278-417` - -Important follow-up: - -- deduplicate message/title mapping shared with `desktop/pi-desktop-runtime.cts` - -Target split: - -- `desktop/pi-threads/shell-loader.cts` -- `desktop/pi-threads/thread-loader.cts` -- `desktop/pi-threads/action-router.cts` - -#### 5. `src/app/components/workspace/Composer.tsx` - -Current mixed responsibilities: - -- home banner -- attachment state and chips -- draft/send workflow -- model picker popup -- thinking-level popup -- footer action strip - -Evidence: - -- `src/app/components/workspace/Composer.tsx:69-101` -- `src/app/components/workspace/Composer.tsx:103-131` -- `src/app/components/workspace/Composer.tsx:133-201` -- `src/app/components/workspace/Composer.tsx:203-247` -- `src/app/components/workspace/Composer.tsx:249-287` -- `src/app/components/workspace/Composer.tsx:312-325` - -Target split: - -- `src/app/components/workspace/composer/AttachmentChips.tsx` -- `src/app/components/workspace/composer/ModelMenu.tsx` -- `src/app/components/workspace/composer/ThinkingMenu.tsx` -- `src/app/components/workspace/composer/useComposerController.ts` - -#### 6. `src/app/components/sidebar/ProjectTree.tsx` - -Current mixed responsibilities: - -- popup menu dismissal state -- project row rendering -- project actions cluster -- thread row rendering/actions -- empty state rendering - -Evidence: - -- `src/app/components/sidebar/ProjectTree.tsx:39-51` -- `src/app/components/sidebar/ProjectTree.tsx:55-150` -- `src/app/components/sidebar/ProjectTree.tsx:152-233` - -Target split: - -- `src/app/components/sidebar/project-tree/ProjectRow.tsx` -- `src/app/components/sidebar/project-tree/ThreadRow.tsx` -- `src/app/components/sidebar/project-tree/useProjectMenuDismiss.ts` -- `src/app/components/sidebar/project-tree/EmptyThreadsState.tsx` - -## Files to leave alone for now - -- `src/app/state/workspace.ts`: focused reducer/selectors and already covered by `src/app/state/workspace.test.ts` -- `src/electron/main/index.ts`: mostly Electron window/bootstrap wiring; revisit only after desktop domain modules are cleaner - -## Recommended implementation order - -### Lane A: shared desktop thread/message utilities - -- extract shared title normalization and message mapping -- make `pi-desktop-runtime.cts` and `pi-threads.cts` consume the same helpers -- add small deterministic tests for the shared pure helpers - -### Lane B: database layer split - -- isolate DB bootstrap and schema -- separate read queries from writes -- keep the public API stable while moving internals first - -### Lane C: runtime/composer split - -- isolate runtime registry -- isolate attachment processing -- isolate thread publishing and desktop event emission -- keep one thin public entry module - -### Lane D: AppShell controller split - -- move effect clusters into hooks -- move action post-processing into a controller/hook -- keep layout rendering in a separate component - -### Lane E: UI decomposition - -- split `Composer.tsx` and `ProjectTree.tsx` -- prefer small presentational pieces and one stateful controller per cluster - -## General refactor rules - -- prefer one reason to change per module -- prefer pure helpers over inline duplicated transforms -- keep public contracts in `shared/` stable while refactoring behind them -- add comments only for invariants, side effects, and non-obvious control flow -- keep deterministic tests close to extracted pure logic - -## UI ARIA and agent traversability audit - -This app is already better than average for automation because many controls are real `