From 5fdc2acedcf9caa2dffc01a0410ca74b1b28d369 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 14:22:54 -0500 Subject: [PATCH 1/7] refactor(desktop): extract composer branch/worktree engine into useComposerBranch Moves the CodingStatusRow hand-offs (openInWorktree + branch-off / convert / list / switch) out of ChatBar into hooks/use-composer-branch.ts, verbatim. The hook depends only on cwd + draftRef + clearDraft (backend coupling via the projects store); nothing about ChatBar's render. Dead projects/composer-store imports drop out of index.tsx. --- .../composer/hooks/use-composer-branch.ts | 95 +++ apps/desktop/src/app/chat/composer/index.tsx | 81 +-- package-lock.json | 547 ------------------ 3 files changed, 101 insertions(+), 622 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts new file mode 100644 index 00000000000..a8e5c65888c --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts @@ -0,0 +1,95 @@ +import { type MutableRefObject, useCallback } from 'react' + +import { clearComposerAttachments } from '@/store/composer' +import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' + +interface UseComposerBranchOptions { + clearDraft: () => void + cwd: null | string | undefined + draftRef: MutableRefObject +} + +/** + * Branch / worktree engine — the `CodingStatusRow` hand-offs. Each action opens + * a fresh session anchored in a worktree carrying the current composer draft as + * its first turn; clearing here means the draft travels to the new session + * instead of getting stashed under this one. Backend coupling (cwd + the + * projects store) is the only dependency; nothing about ChatBar's render. + */ +export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBranchOptions) { + // Hand a worktree off to the controller: open a fresh session anchored there, + // carrying the composer draft as its first turn. Clearing here means the draft + // travels to the new session instead of getting stashed under this one. + const openInWorktree = useCallback( + (path: string) => { + const text = draftRef.current + clearDraft() + clearComposerAttachments() + requestStartWorkSession(path, text) + }, + [clearDraft, draftRef] + ) + + // Branch off into a NEW worktree (base = branch name, or current HEAD). A + // create failure throws back to the row (which toasts) before we touch the + // draft; a missing cwd / remote backend no-ops (the row hides the affordance). + const handleBranchOff = useCallback( + async (branch: string, base?: string) => { + const repoPath = cwd?.trim() + const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch })) + + if (result) { + openInWorktree(result.path) + } + }, + [cwd, openInWorktree] + ) + + // Convert an EXISTING branch into a fresh worktree + session (no new branch). + // Mirrors handleBranchOff's hand-off: create the worktree, then open a session + // anchored there carrying the draft. + const handleConvertBranch = useCallback( + async (branch: string, path?: null | string, isDefault?: boolean) => { + if (path?.trim()) { + openInWorktree(path) + + return + } + + const repoPath = cwd?.trim() + + if (repoPath && isDefault) { + await switchBranchInRepo(repoPath, branch) + openInWorktree(repoPath) + + return + } + + const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch })) + + if (result) { + openInWorktree(result.path) + } + }, + [cwd, openInWorktree] + ) + + const handleListBranches = useCallback(async () => { + const repoPath = cwd?.trim() + + return repoPath ? listRepoBranches(repoPath) : [] + }, [cwd]) + + const handleSwitchBranch = useCallback( + async (branch: string) => { + const repoPath = cwd?.trim() + + if (repoPath) { + await switchBranchInRepo(repoPath, branch) + } + }, + [cwd] + ) + + return { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index d49c8382b7b..648a50ebf40 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -20,7 +20,7 @@ import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { $composerAttachments, clearComposerAttachments } from '@/store/composer' +import { $composerAttachments } from '@/store/composer' import { browseBackward, browseForward, @@ -37,7 +37,6 @@ import { setComposerPoppedOut } from '@/store/composer-popout' import { removeQueuedPrompt } from '@/store/composer-queue' -import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' import { $activeSessionAwaitingInput } from '@/store/prompts' import { toggleReview } from '@/store/review' import { $gatewayState, $messages } from '@/store/session' @@ -62,6 +61,7 @@ import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-aff import { markActiveComposer } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' +import { useComposerBranch } from './hooks/use-composer-branch' import { useComposerDraft } from './hooks/use-composer-draft' import { useComposerDrop } from './hooks/use-composer-drop' import { useComposerMetrics } from './hooks/use-composer-metrics' @@ -966,79 +966,10 @@ export function ChatBar({ handleInputDrop } = useComposerDrop({ cwd, insertInlineRefs, onAttachDroppedItems, requestMainFocus }) - // Hand a worktree off to the controller: open a fresh session anchored there, - // carrying the composer draft as its first turn. Clearing here means the draft - // travels to the new session instead of getting stashed under this one. - const openInWorktree = useCallback( - (path: string) => { - const text = draftRef.current - clearDraft() - clearComposerAttachments() - requestStartWorkSession(path, text) - }, - [clearDraft] - ) - - // Branch off into a NEW worktree (base = branch name, or current HEAD). A - // create failure throws back to the row (which toasts) before we touch the - // draft; a missing cwd / remote backend no-ops (the row hides the affordance). - const handleBranchOff = useCallback( - async (branch: string, base?: string) => { - const repoPath = cwd?.trim() - const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch })) - - if (result) { - openInWorktree(result.path) - } - }, - [cwd, openInWorktree] - ) - - // Convert an EXISTING branch into a fresh worktree + session (no new branch). - // Mirrors handleBranchOff's hand-off: create the worktree, then open a session - // anchored there carrying the draft. - const handleConvertBranch = useCallback( - async (branch: string, path?: null | string, isDefault?: boolean) => { - if (path?.trim()) { - openInWorktree(path) - - return - } - - const repoPath = cwd?.trim() - - if (repoPath && isDefault) { - await switchBranchInRepo(repoPath, branch) - openInWorktree(repoPath) - - return - } - - const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch })) - - if (result) { - openInWorktree(result.path) - } - }, - [cwd, openInWorktree] - ) - - const handleListBranches = useCallback(async () => { - const repoPath = cwd?.trim() - - return repoPath ? listRepoBranches(repoPath) : [] - }, [cwd]) - - const handleSwitchBranch = useCallback( - async (branch: string) => { - const repoPath = cwd?.trim() - - if (repoPath) { - await switchBranchInRepo(repoPath, branch) - } - }, - [cwd] - ) + // Branch / worktree hand-offs (CodingStatusRow). Owns the worktree open + + // branch-off/convert/list/switch actions; draft travels into the new session. + const { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree } = + useComposerBranch({ clearDraft, cwd, draftRef }) // Esc cancels the in-flight turn when the CHAT has focus — not just the // composer input (which has its own handler above). Clicking into the diff --git a/package-lock.json b/package-lock.json index 16a531bd876..4911e054677 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1786,72 +1786,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/windows-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -1890,448 +1824,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -8438,15 +7930,6 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -15561,36 +15044,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", From e009ba57ac4feac11e326db8f4c7da95d9028485 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 14:23:35 -0500 Subject: [PATCH 2/7] refactor(desktop): extract global Esc-to-cancel into useComposerEscCancel Moves the chat-focused Esc-cancel listener (the latest-handler ref + the register-once window keydown effect) out of ChatBar into hooks/use-composer-esc-cancel.ts, verbatim. Encapsulating the latest-closure ref inside its own hook is the first of the plan's "delete the latest-closure refs" cleanups: it's no longer a loose ref in the 1.4k-line component, just an implementation detail of a focused side-effect hook keyed on busy/awaitingInput/ onCancel. --- .../composer/hooks/use-composer-esc-cancel.ts | 54 +++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 42 ++------------- 2 files changed, 57 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts new file mode 100644 index 00000000000..37b3625a4f1 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef } from 'react' + +import { triggerHaptic } from '@/lib/haptics' + +interface UseComposerEscCancelOptions { + awaitingInput: boolean + busy: boolean + onCancel: () => unknown +} + +/** + * Global Esc-to-cancel: stop the in-flight turn when the CHAT (not the composer + * input, which has its own handler) has focus — clicking into the transcript and + * hitting Esc stops the run, matching the Stop button. A latest-handler ref keeps + * the window listener registered exactly once while still reading fresh + * busy/awaitingInput/onCancel each press. + */ +export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseComposerEscCancelOptions) { + // Intentional only: we bail if (a) the composer/another field already handled + // Esc (defaultPrevented), (b) focus is in any input/textarea/contenteditable + // (you're typing, not stopping), or (c) a dialog/popover is open — Esc must + // close that overlay, never double as canceling the stream behind it. + const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {}) + + escCancelRef.current = (event: globalThis.KeyboardEvent) => { + // `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret + // prompt, which owns Esc (or is meant to persist) — never cancel the stream + // out from under it. + if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) { + return + } + + const active = document.activeElement as HTMLElement | null + + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { + return + } + + if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) { + return + } + + event.preventDefault() + triggerHaptic('cancel') + void Promise.resolve(onCancel()) + } + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event) + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, []) +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 648a50ebf40..37ab11043b2 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -64,6 +64,7 @@ import { useAtCompletions } from './hooks/use-at-completions' import { useComposerBranch } from './hooks/use-composer-branch' import { useComposerDraft } from './hooks/use-composer-draft' import { useComposerDrop } from './hooks/use-composer-drop' +import { useComposerEscCancel } from './hooks/use-composer-esc-cancel' import { useComposerMetrics } from './hooks/use-composer-metrics' import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerSubmit } from './hooks/use-composer-submit' @@ -971,45 +972,8 @@ export function ChatBar({ const { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree } = useComposerBranch({ clearDraft, cwd, draftRef }) - // Esc cancels the in-flight turn when the CHAT has focus — not just the - // composer input (which has its own handler above). Clicking into the - // transcript and hitting Esc now stops the run, matching the Stop button. - // Intentional only: we bail if (a) the composer/another field already - // handled Esc (defaultPrevented), (b) focus is in any input/textarea/ - // contenteditable (you're typing, not stopping), or (c) a dialog/popover is - // open — Esc must close that overlay, never double as canceling the stream - // behind it. A latest-handler ref keeps the listener registered once. - const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {}) - - escCancelRef.current = (event: globalThis.KeyboardEvent) => { - // `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret - // prompt, which owns Esc (or is meant to persist) — never cancel the stream - // out from under it. - if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) { - return - } - - const active = document.activeElement as HTMLElement | null - - if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { - return - } - - if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) { - return - } - - event.preventDefault() - triggerHaptic('cancel') - void Promise.resolve(onCancel()) - } - - useEffect(() => { - const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event) - window.addEventListener('keydown', onKeyDown) - - return () => window.removeEventListener('keydown', onKeyDown) - }, []) + // Global Esc-to-cancel when the chat (not the composer input) has focus. + useComposerEscCancel({ awaitingInput, busy, onCancel }) const submitUrl = () => { const url = urlValue.trim() From 2a84bcb1499005d36f028d430225b2aca1554ac3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 14:24:32 -0500 Subject: [PATCH 3/7] refactor(desktop): extract composer "Add URL" dialog into useComposerUrlDialog Moves the URL dialog's open/value state, autofocus-on-open effect, and submit (host onAddUrl or an @url: directive) out of ChatBar into hooks/use-composer-url-dialog.ts, verbatim. ChatBar just wires the returned openUrlDialog into the context menu and the state into . --- .../composer/hooks/use-composer-url-dialog.ts | 50 +++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 41 ++++----------- 2 files changed, 59 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.ts new file mode 100644 index 00000000000..889ed70a06d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef, useState } from 'react' + +import { triggerHaptic } from '@/lib/haptics' + +interface UseComposerUrlDialogOptions { + insertText: (text: string) => void + onAddUrl?: (url: string) => void +} + +/** + * "Add URL" dialog engine: open/value state, autofocus-on-open, and submit. On + * submit it prefers the host's `onAddUrl` (which may fetch/title the link) and + * otherwise drops an `@url:` directive into the draft. + */ +export function useComposerUrlDialog({ insertText, onAddUrl }: UseComposerUrlDialogOptions) { + const urlInputRef = useRef(null) + const [urlOpen, setUrlOpen] = useState(false) + const [urlValue, setUrlValue] = useState('') + + useEffect(() => { + if (urlOpen) { + window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true })) + } + }, [urlOpen]) + + const openUrlDialog = () => { + triggerHaptic('open') + setUrlOpen(true) + } + + const submitUrl = () => { + const url = urlValue.trim() + + if (!url) { + return + } + + if (onAddUrl) { + onAddUrl(url) + } else { + insertText(`@url:${url}`) + } + + triggerHaptic('success') + setUrlValue('') + setUrlOpen(false) + } + + return { openUrlDialog, setUrlOpen, setUrlValue, submitUrl, urlInputRef, urlOpen, urlValue } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 37ab11043b2..0289299ae36 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -68,6 +68,7 @@ import { useComposerEscCancel } from './hooks/use-composer-esc-cancel' import { useComposerMetrics } from './hooks/use-composer-metrics' import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerSubmit } from './hooks/use-composer-submit' +import { useComposerUrlDialog } from './hooks/use-composer-url-dialog' import { useComposerVoice } from './hooks/use-composer-voice' import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' @@ -173,10 +174,6 @@ export function ChatBar({ position: popoutPosition }) - const urlInputRef = useRef(null) - - const [urlOpen, setUrlOpen] = useState(false) - const [urlValue, setUrlValue] = useState('') // Coordinator-owned: the draft engine reads the live queue-edit snapshot off // this ref (to suppress its stash while editing a queued prompt) and the queue // engine writes it — an explicit shared handle, not a back-reference. @@ -215,6 +212,13 @@ export function ChatBar({ stashAt } = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId }) + // "Add URL" dialog — open/value state, autofocus, and submit (host onAddUrl or + // an @url: directive into the draft). + const { openUrlDialog, setUrlOpen, setUrlValue, submitUrl, urlInputRef, urlOpen, urlValue } = useComposerUrlDialog({ + insertText, + onAddUrl + }) + // The queue engine — queued turns, in-place editing, the shared drain lock, // and bounded auto-drain. Consumes the draft API and writes `queueEditRef`. const { @@ -323,12 +327,6 @@ export function ChatBar({ : t.composer.placeholderStarting : restingPlaceholder - useEffect(() => { - if (urlOpen) { - window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true })) - } - }, [urlOpen]) - // Keep the floating box on-screen: re-clamp (with the real measured size + // thread bounds) when it pops out and on every window resize — so a position // persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar @@ -975,24 +973,6 @@ export function ChatBar({ // Global Esc-to-cancel when the chat (not the composer input) has focus. useComposerEscCancel({ awaitingInput, busy, onCancel }) - const submitUrl = () => { - const url = urlValue.trim() - - if (!url) { - return - } - - if (onAddUrl) { - onAddUrl(url) - } else { - insertText(`@url:${url}`) - } - - triggerHaptic('success') - setUrlValue('') - setUrlOpen(false) - } - const { conversation, dictate, @@ -1017,10 +997,7 @@ export function ChatBar({ const contextMenu = ( { - triggerHaptic('open') - setUrlOpen(true) - }} + onOpenUrlDialog={openUrlDialog} onPasteClipboardImage={onPasteClipboardImage} onPickFiles={onPickFiles} onPickFolders={onPickFolders} From a8f9d089f124c7e8ab6689585eb33f9f6565890b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 14:25:21 -0500 Subject: [PATCH 4/7] refactor(desktop): extract composer placeholder logic into useComposerPlaceholder Moves the resting-placeholder state + the conversation-change re-roll effect + the disabled/reconnecting/starting derivation out of ChatBar into hooks/use-composer-placeholder.ts, verbatim. The hook owns its own i18n + browse reset; ChatBar just reads the derived string. --- .../hooks/use-composer-placeholder.ts | 60 +++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 49 ++------------- 2 files changed, 65 insertions(+), 44 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts new file mode 100644 index 00000000000..0c2e4b61927 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from 'react' + +import { useI18n } from '@/i18n' +import { resetBrowseState } from '@/store/composer-input-history' + +import { pickPlaceholder } from '../composer-utils' + +interface UseComposerPlaceholderOptions { + disabled: boolean + reconnecting: boolean + sessionId: null | string | undefined +} + +/** + * The composer's placeholder text. A resting starter (new session) / continuation + * (existing session) is picked once and only re-rolled when we genuinely move to + * a *different* conversation — the null→id persist of a freshly-started session + * keeps its starter so the text doesn't flip mid-stream. While the transport is + * down, it swaps to a reconnecting / starting message instead. + */ +export function useComposerPlaceholder({ disabled, reconnecting, sessionId }: UseComposerPlaceholderOptions): string { + const { t } = useI18n() + const newSessionPlaceholders = t.composer.newSessionPlaceholders + const followUpPlaceholders = t.composer.followUpPlaceholders + + const [restingPlaceholder, setRestingPlaceholder] = useState(() => + pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders) + ) + + const prevSessionIdRef = useRef(sessionId) + + useEffect(() => { + const prev = prevSessionIdRef.current + prevSessionIdRef.current = sessionId + + if (prev === sessionId) { + return + } + + // null → id: the new session we're already in just got persisted. Keep the + // starter we showed instead of swapping to a follow-up under the user. + if (prev == null && sessionId) { + return + } + + resetBrowseState(prev) + setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)) + }, [followUpPlaceholders, newSessionPlaceholders, sessionId]) + + // When the transport is disabled it's because the gateway isn't open. + // Distinguish a cold start ("Starting Hermes...") from a dropped connection + // we're trying to restore. During reconnect, keep the textbox editable so a + // flaky network doesn't block drafting; only submit/backend actions stay + // disabled until the gateway is open again. + return disabled + ? reconnecting + ? t.composer.placeholderReconnecting + : t.composer.placeholderStarting + : restingPlaceholder +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 0289299ae36..bd30911d58a 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -25,8 +25,7 @@ import { browseBackward, browseForward, deriveUserHistory, - isBrowsingHistory, - resetBrowseState + isBrowsingHistory } from '@/store/composer-input-history' import { $composerPopoutPosition, @@ -49,7 +48,6 @@ import { AttachmentList } from './attachments' import { COMPLETION_ACTIONS, COMPOSER_FADE_BACKGROUND, - pickPlaceholder, type QueueEditState, slashArgStage, slashChipKindForItem, @@ -66,6 +64,7 @@ import { useComposerDraft } from './hooks/use-composer-draft' import { useComposerDrop } from './hooks/use-composer-drop' import { useComposerEscCancel } from './hooks/use-composer-esc-cancel' import { useComposerMetrics } from './hooks/use-composer-metrics' +import { useComposerPlaceholder } from './hooks/use-composer-placeholder' import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerSubmit } from './hooks/use-composer-submit' import { useComposerUrlDialog } from './hooks/use-composer-url-dialog' @@ -186,8 +185,6 @@ export function ChatBar({ const { t } = useI18n() const gatewayState = useStore($gatewayState) - const newSessionPlaceholders = t.composer.newSessionPlaceholders - const followUpPlaceholders = t.composer.followUpPlaceholders const reconnecting = gatewayState === 'closed' || gatewayState === 'error' const inputDisabled = disabled && !reconnecting @@ -287,45 +284,9 @@ export function ChatBar({ stashAt }) - // Resting placeholder: a starter for brand-new sessions, a continuation for - // existing ones. Picked once and only re-rolled when we genuinely move to a - // *different* conversation. Critically, the first id assignment of a freshly - // started session (null → id, on the first send) is treated as the same - // conversation so the placeholder doesn't visibly flip mid-stream. - const [restingPlaceholder, setRestingPlaceholder] = useState(() => - pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders) - ) - - const prevSessionIdRef = useRef(sessionId) - - useEffect(() => { - const prev = prevSessionIdRef.current - prevSessionIdRef.current = sessionId - - if (prev === sessionId) { - return - } - - // null → id: the new session we're already in just got persisted. Keep the - // starter we showed instead of swapping to a follow-up under the user. - if (prev == null && sessionId) { - return - } - - resetBrowseState(prev) - setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)) - }, [followUpPlaceholders, newSessionPlaceholders, sessionId]) - - // When the transport is disabled it's because the gateway isn't open. - // Distinguish a cold start ("Starting Hermes...") from a dropped connection - // we're trying to restore. During reconnect, keep the textbox editable so a - // flaky network doesn't block drafting; only submit/backend actions stay - // disabled until the gateway is open again. - const placeholder = disabled - ? reconnecting - ? t.composer.placeholderReconnecting - : t.composer.placeholderStarting - : restingPlaceholder + // Resting / reconnecting / starting placeholder text, re-rolled only on a real + // conversation change. + const placeholder = useComposerPlaceholder({ disabled, reconnecting, sessionId }) // Keep the floating box on-screen: re-clamp (with the real measured size + // thread bounds) when it pops out and on every window resize — so a position From 8795dfa33169b82f6cae7ec22632c430778182d0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 14:26:39 -0500 Subject: [PATCH 5/7] refactor(desktop): extract composer pop-out engine into useComposerPopout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the docked↔floating state, dock/float/toggle actions, drag-gesture wiring, and the on-screen re-clamp effect out of ChatBar into hooks/use-composer-popout.ts, verbatim. ChatBar passes its composerRef in and consumes the returned popout state/handlers; the secondary-window gate and the shared persisted atom stay encapsulated in the hook. --- .../composer/hooks/use-composer-popout.ts | 97 +++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 77 ++------------- 2 files changed, 107 insertions(+), 67 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts new file mode 100644 index 00000000000..3fb0d0372f8 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts @@ -0,0 +1,97 @@ +import { useStore } from '@nanostores/react' +import { type RefObject, useCallback, useEffect } from 'react' + +import { triggerHaptic } from '@/lib/haptics' +import { + $composerPopoutPosition, + $composerPoppedOut, + readPopoutBounds, + setComposerPopoutPosition, + setComposerPoppedOut +} from '@/store/composer-popout' +import { isSecondaryWindow } from '@/store/windows' + +import { useComposerPopoutGestures } from './use-popout-drag' + +interface UseComposerPopoutOptions { + composerRef: RefObject +} + +/** + * Pop-out engine: the docked↔floating state (a shared, persisted atom), the + * dock/float/toggle actions, the drag gestures, and the on-screen re-clamp. + * Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) can't + * pop out — a floating composer makes no sense there and would yank the main + * window's composer out via the shared atom. + */ +export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) { + const popoutAllowed = !isSecondaryWindow() + const poppedOut = useStore($composerPoppedOut) && popoutAllowed + const popoutPosition = useStore($composerPopoutPosition) + + const handleComposerPopOut = useCallback(() => { + triggerHaptic('open') + setComposerPoppedOut(true) + }, []) + + const handleComposerDock = useCallback(() => { + triggerHaptic('success') + setComposerPoppedOut(false) + }, []) + + // Double-click the grab area toggles dock/float. Undocking restores the last + // position (the persisted atom is never cleared on dock). + const handleComposerToggle = useCallback(() => { + poppedOut ? handleComposerDock() : handleComposerPopOut() + }, [handleComposerDock, handleComposerPopOut, poppedOut]) + + const { + dockProximity, + dragging, + onPointerDown: onComposerGesturePointerDown + } = useComposerPopoutGestures({ + composerRef, + onDock: handleComposerDock, + onPopOut: handleComposerPopOut, + poppedOut, + position: popoutPosition + }) + + // Keep the floating box on-screen: re-clamp (with the real measured size + + // thread bounds) when it pops out and on every window resize — so a position + // persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar + // can never strand it. The rAF pass re-clamps after layout settles (sidebar + // widths, fonts), so anyone loading in out of bounds is pulled back + saved + // even if the first measure was premature. + useEffect(() => { + if (!poppedOut) { + return undefined + } + + const reclamp = (persist: boolean) => { + const el = composerRef.current + const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined + setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size }) + } + + reclamp(true) + const raf = requestAnimationFrame(() => reclamp(true)) + const onResize = () => reclamp(false) + window.addEventListener('resize', onResize) + + return () => { + cancelAnimationFrame(raf) + window.removeEventListener('resize', onResize) + } + }, [composerRef, poppedOut]) + + return { + dockProximity, + dragging, + handleComposerToggle, + onComposerGesturePointerDown, + popoutAllowed, + popoutPosition, + poppedOut + } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index bd30911d58a..5503de7a356 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -28,12 +28,7 @@ import { isBrowsingHistory } from '@/store/composer-input-history' import { - $composerPopoutPosition, - $composerPoppedOut, - POPOUT_WIDTH_REM, - readPopoutBounds, - setComposerPopoutPosition, - setComposerPoppedOut + POPOUT_WIDTH_REM } from '@/store/composer-popout' import { removeQueuedPrompt } from '@/store/composer-queue' import { $activeSessionAwaitingInput } from '@/store/prompts' @@ -41,7 +36,6 @@ import { toggleReview } from '@/store/review' import { $gatewayState, $messages } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' import { $autoSpeakReplies } from '@/store/voice-prefs' -import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes' import { AttachmentList } from './attachments' @@ -65,11 +59,11 @@ import { useComposerDrop } from './hooks/use-composer-drop' import { useComposerEscCancel } from './hooks/use-composer-esc-cancel' import { useComposerMetrics } from './hooks/use-composer-metrics' import { useComposerPlaceholder } from './hooks/use-composer-placeholder' +import { useComposerPopout } from './hooks/use-composer-popout' import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerSubmit } from './hooks/use-composer-submit' import { useComposerUrlDialog } from './hooks/use-composer-url-dialog' import { useComposerVoice } from './hooks/use-composer-voice' -import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' import { useSessionStatusPresence } from './hooks/use-status-presence' import { QueuePanel } from './queue-panel' @@ -124,13 +118,6 @@ export function ChatBar({ // would discard a question the user may want to come back to. The blocking // prompt owns its own dismissal (Skip, Reject, dialog close). const awaitingInput = useStore($activeSessionAwaitingInput) - // Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N - // tiny window, subagent watch windows) always start docked and can't pop out: - // a floating composer makes no sense in a single-session side window, and it - // would otherwise write the shared atom and yank the main window's composer out. - const popoutAllowed = !isSecondaryWindow() - const poppedOut = useStore($composerPoppedOut) && popoutAllowed - const popoutPosition = useStore($composerPopoutPosition) const activeQueueSessionKey = queueSessionKey || sessionId || null // Status items (subagents, background processes) are keyed by the RUNTIME @@ -145,33 +132,17 @@ export function ChatBar({ const composerRef = useRef(null) const composerSurfaceRef = useRef(null) - const handleComposerPopOut = useCallback(() => { - triggerHaptic('open') - setComposerPoppedOut(true) - }, []) - - const handleComposerDock = useCallback(() => { - triggerHaptic('success') - setComposerPoppedOut(false) - }, []) - - // Double-click the grab area toggles dock/float. Undocking restores the last - // position (the persisted atom is never cleared on dock). - const handleComposerToggle = useCallback(() => { - poppedOut ? handleComposerDock() : handleComposerPopOut() - }, [handleComposerDock, handleComposerPopOut, poppedOut]) - + // Pop-out engine: docked↔floating state, dock/float/toggle, drag gestures, and + // the on-screen re-clamp. Secondary windows can't pop out. const { dockProximity, dragging, - onPointerDown: onComposerGesturePointerDown - } = useComposerPopoutGestures({ - composerRef, - onDock: handleComposerDock, - onPopOut: handleComposerPopOut, - poppedOut, - position: popoutPosition - }) + handleComposerToggle, + onComposerGesturePointerDown, + popoutAllowed, + popoutPosition, + poppedOut + } = useComposerPopout({ composerRef }) // Coordinator-owned: the draft engine reads the live queue-edit snapshot off // this ref (to suppress its stash while editing a queued prompt) and the queue @@ -288,34 +259,6 @@ export function ChatBar({ // conversation change. const placeholder = useComposerPlaceholder({ disabled, reconnecting, sessionId }) - // Keep the floating box on-screen: re-clamp (with the real measured size + - // thread bounds) when it pops out and on every window resize — so a position - // persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar - // can never strand it. The rAF pass re-clamps after layout settles (sidebar - // widths, fonts), so anyone loading in out of bounds is pulled back + saved - // even if the first measure was premature. - useEffect(() => { - if (!poppedOut) { - return undefined - } - - const reclamp = (persist: boolean) => { - const el = composerRef.current - const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined - setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size }) - } - - reclamp(true) - const raf = requestAnimationFrame(() => reclamp(true)) - const onResize = () => reclamp(false) - window.addEventListener('resize', onResize) - - return () => { - cancelAnimationFrame(raf) - window.removeEventListener('resize', onResize) - } - }, [poppedOut]) - const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) const [triggerItems, setTriggerItems] = useState([]) From b48fcfa8bd93268647c77113900f603ac7f7a93b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 14:27:19 -0500 Subject: [PATCH 6/7] test(desktop): unit-cover the composer URL-dialog engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds hooks/use-composer-url-dialog.test.tsx (renderHook): @url: directive fallback, host onAddUrl preference + clear/close, and the blank-input no-op. First unit coverage for an extracted composer engine — previously none of this logic was testable while welded into the DOM-coupled ChatBar. --- .../hooks/use-composer-url-dialog.test.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.test.tsx diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.test.tsx new file mode 100644 index 00000000000..060dd86514e --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.test.tsx @@ -0,0 +1,48 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { useComposerUrlDialog } from './use-composer-url-dialog' + +vi.mock('@/lib/haptics', () => ({ triggerHaptic: () => {} })) + +describe('useComposerUrlDialog', () => { + it('drops an @url: directive into the draft when there is no host onAddUrl', () => { + const insertText = vi.fn() + const { result } = renderHook(() => useComposerUrlDialog({ insertText })) + + act(() => result.current.setUrlValue(' https://example.dev ')) + act(() => result.current.submitUrl()) + + // The trailing/leading whitespace is trimmed before building the directive. + expect(insertText).toHaveBeenCalledWith('@url:https://example.dev') + }) + + it('prefers the host onAddUrl handler, then clears + closes the dialog', () => { + const insertText = vi.fn() + const onAddUrl = vi.fn() + const { result } = renderHook(() => useComposerUrlDialog({ insertText, onAddUrl })) + + act(() => { + result.current.openUrlDialog() + result.current.setUrlValue(' https://example.dev ') + }) + act(() => result.current.submitUrl()) + + expect(onAddUrl).toHaveBeenCalledWith('https://example.dev') + expect(insertText).not.toHaveBeenCalled() + expect(result.current.urlValue).toBe('') + expect(result.current.urlOpen).toBe(false) + }) + + it('no-ops on an empty / whitespace-only URL', () => { + const insertText = vi.fn() + const onAddUrl = vi.fn() + const { result } = renderHook(() => useComposerUrlDialog({ insertText, onAddUrl })) + + act(() => result.current.setUrlValue(' ')) + act(() => result.current.submitUrl()) + + expect(insertText).not.toHaveBeenCalled() + expect(onAddUrl).not.toHaveBeenCalled() + }) +}) From 8e675f65646fa16cfc6805e61e17715ecbe781c1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 14:40:12 -0500 Subject: [PATCH 7/7] chore(desktop): restore package-lock.json (drop stray npm-install churn) --- package-lock.json | 547 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 547 insertions(+) diff --git a/package-lock.json b/package-lock.json index 4911e054677..16a531bd876 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1786,6 +1786,72 @@ "node": ">= 10.0.0" } }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/windows-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -1824,6 +1890,448 @@ "dev": true, "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -7930,6 +8438,15 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -15044,6 +15561,36 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",