diff --git a/.github/workflows/AGENTS.md b/.github/workflows/AGENTS.md new file mode 100644 index 00000000..a5fc45fe --- /dev/null +++ b/.github/workflows/AGENTS.md @@ -0,0 +1,4 @@ +- Pages deploys from `main` only. Do not suggest adding `dev` unless the release flow changes. +- PRs to `dev` must not build release artifacts. Only `push`/tag/manual runs build, publish, or upload release assets. +- `package.json` and `packages/howcode/package.json` do not have to match. Root tracks app artifacts; `packages/howcode` tracks the npm launcher only. +- The launcher should keep picking up current `main`/`dev` channel assets without an npm publish unless launcher code changes. diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 101455b7..2bedef98 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,7 +2,7 @@ name: Deploy GitHub Pages on: push: - branches: [dev] + branches: [main] workflow_dispatch: permissions: diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index c9409ed6..25c38fa3 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -4,9 +4,6 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true on: - pull_request: - branches: - - dev push: branches: - dev @@ -83,12 +80,6 @@ jobs: shell: bash run: | ROOT_VERSION=$(node -p "require('./package.json').version") - LAUNCHER_VERSION=$(node -p "require('./packages/howcode/package.json').version") - if [ "$ROOT_VERSION" != "$LAUNCHER_VERSION" ]; then - echo "Version mismatch detected" - echo "root=$ROOT_VERSION launcher=$LAUNCHER_VERSION" - exit 1 - fi if [ "${GITHUB_REF_TYPE}" = "tag" ]; then TAG_VERSION="${GITHUB_REF_NAME#v}" if [ "$ROOT_VERSION" != "$TAG_VERSION" ]; then @@ -106,6 +97,8 @@ jobs: bun run build:release - name: Build launcher archives + env: + HOWCODE_RELEASE_ASSET_BASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ github.ref_type == 'tag' && github.ref_name || format('channel-{0}', github.ref_name) }} run: bun run build:launcher-artifacts - name: Upload dev artifacts @@ -231,11 +224,31 @@ jobs: - name: List channel release assets run: find release-assets -maxdepth 2 -type f \( -name 'stable-*-update.json' -o -name '*.tar.gz' -o -name '*.AppImage' -o -name '*.exe' \) -print | sort + - name: Prepare channel release notes + env: + CHANNEL: ${{ github.ref_name }} + run: | + node - <<'NODE' + const fs = require('node:fs') + const changelog = fs.readFileSync('docs/changelog.md', 'utf8') + const match = changelog.match(/^###\s+([^\n]+)\n([\s\S]*?)(?=^###\s+|(?![\s\S]))/m) + const version = match?.[1]?.trim() || 'current' + const notes = match?.[2]?.trim() || 'See the changelog for details.' + const channel = process.env.CHANNEL + const title = channel === 'dev' ? `howcode dev (${version})` : `howcode ${version}` + const channelNote = channel === 'dev' + ? 'This is the moving dev channel used by `npx howcode@dev`.' + : 'This is the moving stable channel used by `npx howcode`.' + fs.writeFileSync('channel-release-title.txt', `${title}\n`) + fs.writeFileSync('channel-release-notes.md', `## ${title}\n\n${notes}\n\n### Release channel\n\n${channelNote} Future app updates refresh these GitHub Release assets without requiring an npm package publish unless the launcher itself changes.\n`) + NODE + - name: Create or update channel GitHub release env: GH_TOKEN: ${{ github.token }} CHANNEL: ${{ github.ref_name }} run: | + RELEASE_TAG="channel-$CHANNEL" mapfile -d '' assets < <(find release-assets -type f \( -name 'stable-*-update.json' -o -name '*.tar.gz' -o -name '*.AppImage' -o -name '*.exe' \) -print0 | sort -z) if [ ${#assets[@]} -eq 0 ]; then echo "No release assets found" @@ -249,15 +262,17 @@ jobs: release_flags+=(--latest) fi - if gh release view "$CHANNEL" >/dev/null 2>&1; then - mapfile -t old_assets < <(gh release view "$CHANNEL" --json assets --jq '.assets[].name | select(test("^(stable-.*-update\\.json|howcode-.*\\.tar\\.gz|.*\\.AppImage|.*\\.exe)$"))') + release_title=$(cat channel-release-title.txt) + + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + mapfile -t old_assets < <(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name | select(test("^(stable-.*-update\\.json|howcode-[^-]+-[^-]+\\.tar\\.gz|archive-howcode-[^-]+-[^-]+-[a-f0-9]{64}\\.tar\\.gz|.*\\.AppImage|.*\\.exe)$"))') for old_asset in "${old_assets[@]}"; do - gh release delete-asset "$CHANNEL" "$old_asset" --yes + gh release delete-asset "$RELEASE_TAG" "$old_asset" --yes done - gh release upload "$CHANNEL" "${assets[@]}" --clobber - gh release edit "$CHANNEL" --title "$CHANNEL" --target "$GITHUB_SHA" "${release_flags[@]}" + gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber + gh release edit "$RELEASE_TAG" --title "$release_title" --notes-file channel-release-notes.md --target "$GITHUB_SHA" "${release_flags[@]}" else - gh release create "$CHANNEL" "${assets[@]}" --title "$CHANNEL" --target "$GITHUB_SHA" --notes "Automated $CHANNEL build." "${release_flags[@]}" + gh release create "$RELEASE_TAG" "${assets[@]}" --title "$release_title" --target "$GITHUB_SHA" --notes-file channel-release-notes.md "${release_flags[@]}" fi publish-release: diff --git a/.gitignore b/.gitignore index fa095610..78a9431f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ artifacts/ coverage/ .pi/semantic-grep.sqlite +.pi/semantic-grep.json +.pi/semantic-grep.sync-conflict-*.sqlite .pi/semantic-grep.sqlite-shm .pi/semantic-grep.sqlite-wal diff --git a/.pi/skills/gh-issue-pr-flow/SKILL.md b/.pi/skills/gh-issue-pr-flow/SKILL.md index ebbad4d1..16373ac5 100644 --- a/.pi/skills/gh-issue-pr-flow/SKILL.md +++ b/.pi/skills/gh-issue-pr-flow/SKILL.md @@ -11,7 +11,8 @@ Use GitHub issues as the working brief for this repo. This skill covers four rel 1. pick the right issue to work on 2. rewrite vague issues into proper issues, epics, and sub-issues 3. implement issue-backed work on a fresh branch from `dev` -4. open and maintain clean PRs with good GitHub hygiene +4. choose single-PR vs stacked-PR shape +5. open and maintain clean PRs with good GitHub hygiene Do not treat every issue as immediately buildable. First decide whether it is a good leaf issue, a parent issue, a research item, or something that should be rewritten before coding. @@ -24,10 +25,12 @@ Do not treat every issue as immediately buildable. First decide whether it is a - When rewriting an existing issue, preserve the user's original text at the bottom under `## Original issue text` as a blockquote. - New or rewritten issues should follow the writing rules in `references/issue-writing.md`. - If PR or release work touches GitHub Actions, artifact uploads, or release packaging, check `references/actions-artifact-retention.md` so workflow changes match the repo's storage policy. -- Start new work from a fresh local branch based on `dev`. -- Open the PR back into `dev`, not `main`, unless the user explicitly says otherwise. +- Start new work from fresh `dev`. For single PRs, create a normal branch from `dev`; for stacked work, initialize the stack with `--base dev`. +- Open PRs back into `dev`, not `main`, unless the user explicitly says otherwise. In stacked mode, the bottom PR ultimately targets `dev` and upper PRs target the layer below. - If the issue asks for discussion first or is clearly not actionable yet, stop and explain instead of forcing implementation. -- Only use auto-closing keywords such as `Closes #123` for issues that are fully resolved by the PR. Use `Refs #123` for anything partial. +- Choose the PR shape deliberately: small related changes should usually become layers in a stack; big unrelated features should be separate PRs or separate stacks; GitHub epics usually become a stacked PR series rather than one giant PR. +- Use the `gh-stack` skill as the operating manual when stacked PR mode is selected; this skill owns the issue/PR policy and stack-vs-single decision. +- Only use auto-closing keywords such as `Closes #123` for issues that are fully resolved by the PR. Use `Refs #123` for anything partial. In stacked mode, use `Refs` on intermediate layers and reserve `Closes` for the layer/PR whose merge completes the issue. - Before any build or release pass in this flow, refresh the landing changelog in `src/app/views/landing-overview-content.ts` from recent merged PRs / `origin/dev` commits so the shipped app does not carry stale release notes. ## When to use @@ -99,13 +102,25 @@ Avoid the shorter `gh issue view --comments` as the first read: it can - `Backlog` means valid but not next - epics are usually containers, not active implementation cards -### 4. Branch from `dev` -1. Fetch remotes. -2. Switch to `dev`. -3. Fast-forward `dev` from `origin/dev`. -4. Create a fresh branch from `dev` for the issue work. +### 4. Choose PR shape, then branch from `dev` +1. Fetch remotes, switch to `dev`, and fast-forward from `origin/dev`. +2. Decide whether this work should be a single PR, a stacked PR series, or separate independent PRs/stacks. +3. Use single PR mode only for tiny self-contained leaf work where a stack would be empty ceremony. +4. Use stacked PR mode for small related changes that form one cohesive story with reviewable dependency layers. Treat most GitHub epics this way: split the epic into bottom-to-top PR layers instead of making one huge branch. +5. Use separate PRs/stacks for big features that can merge independently or belong to different product stories. -Useful commands: +Decision tree: + +```text +Is this local-only or not issue-backed? → leave this skill. +Is the issue not actionable yet? → rewrite/split/research first. +Is it one tiny self-contained leaf change? → one normal PR from dev. +Is it small related changes or one cohesive feature/epic with dependent layers? → stacked PRs, base dev. +Are the parts big, independent, or unrelated? → separate PRs or separate stacks. +Would a stack only add ceremony? → one normal PR. +``` + +Useful commands for single PR mode: ```bash git fetch origin @@ -114,7 +129,22 @@ git pull --ff-only origin dev git switch -c ``` -Branch naming should be issue-oriented and easy to trace, for example `issue-123-short-slug` or `issues-123-124-short-slug`. +Useful commands for stacked PR mode: + +```bash +git fetch origin +git switch dev +git pull --ff-only origin dev +gh stack init --base dev -p issue-123 foundation +# commit foundation layer +gh stack add api +# commit next layer +gh stack add ui +# commit top layer +gh stack submit --auto +``` + +Branch naming should be issue-oriented and easy to trace. For stacks, prefer a shared prefix such as `issue-123` with short layer suffixes. ### 5. Implement the work 1. Treat the issue as the brief. @@ -134,10 +164,11 @@ Branch naming should be issue-oriented and easy to trace, for example `issue-123 3. Keep it terse and user-facing; do not dump issue numbers or internal workflow noise into the app UI. 4. If nothing user-visible changed since the last refresh, say so explicitly instead of inventing changelog churn. -### 7. Open a clean PR with good hygiene -1. Push the branch if needed. -2. Create a PR targeting `dev`. -3. Write a detailed PR body with: +### 7. Open clean PRs with good hygiene +1. Push the branch or submit the stack if needed. +2. In single PR mode, create one PR targeting `dev`. +3. In stacked mode, create/update the stack with `gh stack submit --auto`; verify with `gh stack view --json`. +4. Write detailed PR bodies with: - a concise summary of the change - notable implementation details if they matter to reviewers - any validation or review loop summary worth preserving @@ -149,18 +180,35 @@ Branch naming should be issue-oriented and easy to trace, for example `issue-123 Useful commands: ```bash +# single PR git push -u origin gh pr create --base dev --fill gh pr edit --body-file + +# stacked PRs +gh stack submit --auto +gh stack view --json +# edit individual PR bodies with gh pr edit as needed ``` +Stacked PR body guidance: +- Intermediate layers usually use `Refs #n`. +- The final/completing layer may use `Closes #n` if merging that layer resolves the issue. +- For epic stacks, child issue PRs can close their child issues; parent epics should usually be referenced until the full epic is complete. + ### 8. Ask Codex for review -Post this exact PR comment after the PR is up: +Post this exact PR comment after a normal PR is up: ```text @codex please review this PR and give me 10-20 issues if any. Categorize findings as required, recommended, or optional. ``` +For stacked PRs, post this variant on each PR in the stack: + +```text +@codex please review this PR as one layer in a stacked PR series. Focus on this layer's diff and call out cross-layer issues only when they affect correctness. Categorize findings as required, recommended, or optional. +``` + Useful command: ```bash @@ -194,13 +242,13 @@ gh pr view --comments - If the user asked for issue selection, the chosen issue is a manageable leaf issue rather than an epic or mushy umbrella issue. - If the user asked for issue cleanup, the rewritten issues follow the house format and preserve original text. - The issue or PR was fully read before action. -- New work started from a branch created off current `dev`. -- The PR targets `dev` explicitly. +- New single-PR work started from a branch created off current `dev`; stacked work was initialized with `--base dev`. +- The PR targets `dev` explicitly, or the stack bottom ultimately targets `dev` with upper PRs targeting the layer below. - The PR body clearly distinguishes `Closes` from `Refs`. - The user's `/review` findings were addressed or explicitly called out. - If the flow included a build or release pass, `src/app/views/landing-overview-content.ts` was refreshed first from real merged PRs / `origin/dev` history. - If the flow touched GitHub Actions workflows, artifact retention and upload behavior still match `references/actions-artifact-retention.md`. -- Codex was asked for review. +- Codex was asked for review; in stacked mode, each layer received the stack-aware review request. - The PR link was returned to the user after posting the review request. - Codex feedback was triaged instead of accepted blindly when the user asked for feedback handling. @@ -229,8 +277,8 @@ Action: do not churn on them. Fix the real ones, then note why the others were d ## Output contract The completed flow should leave: - a clearly chosen or clearly rewritten issue when the request was about backlog triage -- an issue-backed implementation branch created from `dev` -- a PR targeting `dev` +- an issue-backed implementation branch from `dev`, or a stack initialized with `--base dev` +- a PR targeting `dev`, or a stacked PR series whose bottom targets `dev` - a detailed PR description with correct closing references - a Codex review request comment on the PR - the PR link returned to the user diff --git a/.pi/skills/gh-stack/SKILL.md b/.pi/skills/gh-stack/SKILL.md new file mode 100644 index 00000000..46927c91 --- /dev/null +++ b/.pi/skills/gh-stack/SKILL.md @@ -0,0 +1,204 @@ +--- +name: gh-stack +description: Manage GitHub stacked pull requests with `gh stack`. Use when creating stacked diffs, splitting work into dependent PRs, pushing/submitting/syncing/rebasing a stack, checking stack status, or navigating branch layers. Do not use for ordinary single-branch PR work. +--- + +# gh-stack + +## Purpose + +Use `gh stack` to operate a linear chain of dependent branches and PRs. This is the CLI operating manual; higher-level repo policy decides whether work should be stacked. Each branch builds on the one below it; the bottom branch targets trunk, and each higher PR targets the branch below. + +```text +main <- feat/base <- feat/api <- feat/ui + bottom top +``` + +## Critical rules + +- Run `gh stack` non-interactively. Avoid commands or flags that open prompts/TUIs. +- Use `gh stack view --json`, never plain `gh stack view`. +- Use `gh stack submit --auto`, never plain `gh stack submit`. +- Pass branch names to `init`, `add`, and `checkout`; do not rely on prompts. +- If a stack has a prefix, pass only suffixes to `add`: with prefix `feat`, use `gh stack add api`, not `gh stack add feat/api`. +- Put changes in the right layer. If a higher layer needs a lower-layer change, move down, commit there, then `gh stack rebase --upstack`. +- Prefer normal `git add` and `git commit` over `gh stack add -Am` unless the change is clearly a single-commit layer. + +## Setup + +If the extension is missing: + +```bash +gh extension install github/gh-stack +``` + +Before stack work in a repo, reduce prompt risk: + +```bash +git config rerere.enabled true +git config remote.pushDefault origin +``` + +If multiple remotes exist and `remote.pushDefault` is not set, pass `--remote ` to `push`, `submit`, `sync`, `link`, and checkout/rebase flows that fetch or push. + +## Common workflows + +### Create a new stack + +```bash +gh stack init -p feat base-layer +# edit files +git add +git commit -m "Add base layer" + +gh stack add api-layer +# edit files +git add +git commit -m "Add API layer" + +gh stack add ui-layer +# edit files +git add +git commit -m "Add UI layer" + +gh stack submit --auto +gh stack view --json +``` + +Use dependency order: shared types/schema/core code at the bottom; callers, UI, tests, and integration work above. + +### Adopt existing branches + +```bash +gh stack init --base main --adopt branch-a branch-b branch-c +gh stack submit --auto +``` + +List branches bottom-to-top. + +### Check status + +```bash +gh stack view --json +``` + +Inspect `branches[].name`, `branches[].isCurrent`, `branches[].needsRebase`, `branches[].isMerged`, and `branches[].pr`. + +### Push or submit + +```bash +gh stack push # push branches only +gh stack submit --auto # push and create/update PRs as draft by default +gh stack submit --auto --open +``` + +### Sync routine updates + +```bash +gh stack sync +``` + +This fetches, fast-forwards trunk when possible, rebases stack branches, pushes, and refreshes PR state. + +### Modify a lower layer + +```bash +gh stack checkout feat/api-layer # or: gh stack down / gh stack bottom +# edit files +git add +git commit -m "Fix API layer" +gh stack rebase --upstack +gh stack push +``` + +Do not commit lower-layer work on an upper branch just because that is where you noticed it. + +### Navigate + +```bash +gh stack up [n] +gh stack down [n] +gh stack top +gh stack bottom +gh stack checkout +``` + +Always provide an argument to `checkout`. + +### Rebase and conflicts + +```bash +gh stack rebase +gh stack rebase --upstack +gh stack rebase --downstack +``` + +On conflict: + +1. Read stderr for conflicted paths. +2. Resolve conflict markers in files. +3. `git add `. +4. `gh stack rebase --continue`. +5. If resolution is unsafe, `gh stack rebase --abort`. + +### Restructure a stack + +For reorder/rename/drop operations, prefer the interactive `gh stack modify` only when a human can operate the TUI. For agent-safe restructuring: + +```bash +gh stack unstack --local # use without --local only when intentionally changing GitHub stack state +# rename/delete/reorder branches with git +gh stack init --base main --adopt new-bottom new-middle new-top +``` + +Ask before destructive branch deletion or unstacking GitHub state. + +### Link PRs without local stack state + +Use this for branches managed by another tool, or when only GitHub stack linkage is needed: + +```bash +gh stack link --base main branch-a branch-b branch-c +gh stack link 101 102 103 +``` + +Arguments are bottom-to-top. + +## Command reference + +| Task | Command | +| --- | --- | +| Create stack with prefix | `gh stack init -p feat base` | +| Adopt branches | `gh stack init --base main --adopt branch-a branch-b` | +| Add top branch | `gh stack add next-layer` | +| View JSON | `gh stack view --json` | +| Push branches | `gh stack push` | +| Submit PRs | `gh stack submit --auto` | +| Submit ready PRs | `gh stack submit --auto --open` | +| Sync stack | `gh stack sync` | +| Rebase whole stack | `gh stack rebase` | +| Rebase above current branch | `gh stack rebase --upstack` | +| Continue conflict resolution | `gh stack rebase --continue` | +| Abort rebase | `gh stack rebase --abort` | +| Move through layers | `gh stack up`, `gh stack down`, `gh stack top`, `gh stack bottom` | +| Checkout stack/branch | `gh stack checkout ` | +| Remove local tracking | `gh stack unstack --local` | +| Link existing PRs/branches | `gh stack link ` | + +## Failure handling + +- Extension missing: install `github/gh-stack` and retry. +- Repository not enabled for GitHub Stacked PRs: `submit`/`link` can fail; report that the GitHub feature must be enabled for the repo. +- Command hangs: assume an interactive prompt/TUI was triggered; cancel and rerun with explicit args/flags. +- Multiple remotes: set `git config remote.pushDefault origin` or pass `--remote `. +- Branch belongs to multiple stacks: check out a specific non-shared stack branch before retrying. +- Remote checkout conflict prompt risk: if local and remote stack composition differ, remove local tracking with `gh stack unstack --local` before `gh stack checkout `. + +## Output contract + +When using this skill, final responses should include: + +- stack branches changed or created, in bottom-to-top order +- commands run that affected stack state +- PR URLs/numbers when created or updated +- any unresolved conflict, prompt, or repository feature blocker diff --git a/AGENTS.md b/AGENTS.md index 928b935f..09bf2097 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,14 +1,13 @@ ## Stack - Use Bun for installs and scripts; keep the app runtime on Node.js/Electron. -- Biome for formatting, linting, and import organization. -- `tsgo --noEmit` via `@typescript/native-preview` for type checking. -- `bun run ai:check` is the repo-wide verification command. +- `bun run ai:check` is the repo-wide verification command; do not run standalone formatting or typechecking commands. ## Code Quality -- MUST run `bun run ai:check` after concluding any changes. -- Run `bun run ai:check` frequently while working and always before considering the task complete. +- Run `bun run ai:check` after meaningful code, config, packaging, or behavior changes. +- Do not run `bun run ai:check` for docs-only or AGENTS.md-only edits unless they change commands, configuration, packaging, workflow behavior, or explicit validation instructions. +- Run `bun run ai:check` frequently while working on code and before considering substantive implementation work complete. - If you touch a subsystem with its own fast deterministic tests, run those too. -- Do not consider work complete while `ai:check` is failing. +- Do not consider substantive implementation work complete while `ai:check` is failing. - Never weaken strict Biome or TypeScript rules just to silence warnings quickly. Fix the issue properly or add a narrow, justified override. ## Project Workflow diff --git a/bun.lock b/bun.lock index 2b2c61df..50c1630b 100644 --- a/bun.lock +++ b/bun.lock @@ -53,8 +53,8 @@ "electron": "41.2.1", "electron-builder": "^26.0.12", "husky": "^9.1.7", - "knip": "^6.12.1", "lint-staged": "^15.2.7", + "react-doctor": "^0.1.6", "react-grab": "^0.1.32", "tailwindcss": "^4.2.4", "typescript": "^6.0.3", @@ -348,6 +348,8 @@ "@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=="], + "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -564,6 +566,44 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-2r6Nq3XXGLHEXKkSj8JtmJ6N4gDw431DPFOg0ZoJHlNjnG6HVMm/ksQ10m0HJ8WBvwgMe1L50UHPaYZutCRPCw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.64.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ePJMpePgg7fBv+L/hVx1xXRU5/5gd5m0obLA6hPEfLXF3GjpR8idIDbY1dhQYhyz1ms2wdTccSboo6KEd2Oxtg=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.64.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-U4DMLQd10gJLuoSTLSGbfv3bGjTlUNsScm9Dgb8wwBqmCzidf1pE1pXV4doGNxqwH3KtVng1AGTINA0NvkGLvQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.64.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-GoRIL48QWm4/TAvjN8pB1nAG+1/uqc9EdnWT9zqHeb6wsmjZtywj8VRe5aGW47Fdb64YtLOsdLqVxOvQuz98Wg=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.64.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5dFkv4tkg7PxJJGS9/OjrJwjhuHczrd3OQOkRE0wHcLM+ncUnULtzEPWjqGOxTXxZnLWcB91bGiIznx89TVXyQ=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jsBqMLl/uOL5+Kq/+BtK9FrmiNGUbx8SiyZXv+WlUxA45KuwcLu9BfiSIL3I3DBDgWM3yZizDITnTK9BcqNBQg=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-1lrj8At/Uuc9GhjrVFBQo0NEjfBrTkzpmtHIGAhNnIXqn1CAyGL+qrztUsXb2GIluJrpl9Q7qRLJOb/NqydacQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HpSQbubwh03mMhAdy2BYtad/fsY8vDFHDAb6bUwuCYg2VD3xCQgn6ArKcO0oZyLCheacKTv4PrF3Mfu5hgoE2g=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-00QQ0h0Y7u0G69BgiH3+ky2aaq/QvkDL6DYok8htIuJHxybiux5aQ8jwmg8qIk9wha6UagUP2BAwAzbemcJbpg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.64.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2GaimTV6EMW+s5HS0An3oGbQme3BgHswvfVdGk3EB57Xe9+/gyT+Qd7lNVzb3rtir52vbIPzXfaYArzs5b5zcw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-H46AtFb9wypjoVwGdlxrm0DsD809NGmtiK9HiyPKTxkSte2YjhC4S+00rOIrwCaxcyPiGid3Y3OMXp5KMAkGZw=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-HEgsidjjvvyzdg82icYkuFCf7REDV7B9JFwbIMbVwrKLBY0MrXX+bku3POn/hduZ2yW91IyVDUMq0Bf02KwXQw=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.64.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Axvm8qryotmKN00P5w4JapaSjvP2LOSbdbBJiX+2SuHd3QzhW7TUc8skqgw+ahQZ5DmzEYeHCqauvW8f32Ns6Q=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cR60vSd7+m+KRZ3GQGfDxWwahW5RMXg0qlGvAluZr0fTUYvw0H9N9AXAF/M/PMqgytyqvVNmBAkJG9l7U30Y1g=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2u/aPZ9pEg7HnvZPDsHxUGNnrpr4qaHi+mCgLgpt+LYRzPrS4Px4wPfkIdRdr2GvKnaYyt+XSlto0Vm5sbStTg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.64.0", "", { "os": "none", "cpu": "arm64" }, "sha512-kfhkGfCdoXLSxEkrhDlJrvBYajGmq+ma4EMc53dsOWTq+rIBOlI0vTBmpZNnM5oH2LY/K/w1HAK+UQEgjgpVUg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.64.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-r/cNKBFieONoVu2bb1KkVouq9W+edDUgHumXJGphCRRj+U0xaD4nanrw8ZOqo0IsutPkEM4vCcGBpak6x5aXMg=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.64.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-tUw0xUUwEFVZbpJoeCblkv8SJA4Xz3CdXCJbAnBsiNLyxDrk2tLcxEAS6M73Q7hHHDg3OtwI8vZVK3t5RJt4Gw=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-9CBR+LO0JVST87fNTzzNxS5I29jIUO5gxT9i9+M3SDHHALElj9sY1Prf12tad3vIRC6OD7Ehtvvh+sn13vSwHw=="], + "@pierre/diffs": ["@pierre/diffs@1.1.21", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-4vz4YRg1qZEiVwx6EnaYlMSIIDOq1CvtcBEc4b/gNxDbQtlvGJof+IWH5cv/bwgDre377Txe/ML4zoSp78yWWw=="], "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], @@ -980,6 +1020,8 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], @@ -1798,6 +1840,8 @@ "oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="], + "oxlint": ["oxlint@1.64.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.64.0", "@oxlint/binding-android-arm64": "1.64.0", "@oxlint/binding-darwin-arm64": "1.64.0", "@oxlint/binding-darwin-x64": "1.64.0", "@oxlint/binding-freebsd-x64": "1.64.0", "@oxlint/binding-linux-arm-gnueabihf": "1.64.0", "@oxlint/binding-linux-arm-musleabihf": "1.64.0", "@oxlint/binding-linux-arm64-gnu": "1.64.0", "@oxlint/binding-linux-arm64-musl": "1.64.0", "@oxlint/binding-linux-ppc64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-musl": "1.64.0", "@oxlint/binding-linux-s390x-gnu": "1.64.0", "@oxlint/binding-linux-x64-gnu": "1.64.0", "@oxlint/binding-linux-x64-musl": "1.64.0", "@oxlint/binding-openharmony-arm64": "1.64.0", "@oxlint/binding-win32-arm64-msvc": "1.64.0", "@oxlint/binding-win32-ia32-msvc": "1.64.0", "@oxlint/binding-win32-x64-msvc": "1.64.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Star3SNpWPeWFPw7kRXIhXUSn6fdiAl25q15CQzH/9WaOtG6e9CWTc25vNZOCr4PE1yEP1GtKJKIKglhj3OmEQ=="], + "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -1888,6 +1932,8 @@ "react-devtools-inline": ["react-devtools-inline@4.4.0", "", { "dependencies": { "es6-symbol": "^3" } }, "sha512-ES0GolSrKO8wsKbsEkVeiR/ZAaHQTY4zDh1UW8DImVmm8oaGLl3ijJDvSGe+qDRKPZdPRnDtWWnSvvrgxXdThQ=="], + "react-doctor": ["react-doctor@0.1.6", "", { "dependencies": { "agent-install": "0.0.5", "commander": "^14.0.3", "knip": "^6.10.0", "ora": "^9.4.0", "oxlint": "^1.63.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "typescript": ">=5.0.4 <7" }, "peerDependencies": { "eslint-plugin-react-hooks": "^6 || ^7", "eslint-plugin-react-you-might-not-need-an-effect": "^0.10" }, "optionalPeers": ["eslint-plugin-react-hooks", "eslint-plugin-react-you-might-not-need-an-effect"], "bin": { "react-doctor": "bin/react-doctor.js" } }, "sha512-HvUqkqvxfgWkUTiCsPWlDuJv/voNyPmZzrOruHYCzxt13at6IOaTjXSoImcBCWD8bD7GSuOu9td65yx6Zkeflg=="], + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], "react-error-boundary": ["react-error-boundary@3.1.4", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA=="], @@ -2300,6 +2346,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], @@ -2400,6 +2448,8 @@ "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "react-doctor/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index 3e696812..5fd46880 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -1,5 +1,4 @@ - `desktop/` is backend runtime code for threads, skills, and terminal lanes. - Keep entrypoints stable: `pi-threads.ts`, `pi-skills.ts`, `skill-creator-session.ts`, and `terminal/manager.ts`. - Update `shared/*` contracts and the matching action router/handlers together. -- After `desktop/**/*.ts` edits, rebuild with `bun ./scripts/build-electron-runtime.ts` or `bun run dev:runtime`. - Keep event/state flow on the existing publisher and `thread-state-db.ts` paths. diff --git a/desktop/app-settings/keys.ts b/desktop/app-settings/keys.ts index 167f4562..be242b43 100644 --- a/desktop/app-settings/keys.ts +++ b/desktop/app-settings/keys.ts @@ -21,6 +21,8 @@ export const gitDiffFileTreeDefaultVisibleKey = 'gitDiffFileTreeDefaultVisible' export const projectDeletionModeKey = 'projectDeletionMode' export const useAgentsSkillsPathsKey = 'useAgentsSkillsPaths' export const howcodeNativeAskQuestionsKey = 'howcodeNativeAskQuestions' +export const devUpdateBranchKey = 'devUpdateBranch' +export const legacyDevUpdateBranchKey = 'betaUpdateBranch' export const piTuiTakeoverKey = 'piTuiTakeover' export const hoverToFocusKey = 'hoverToFocus' export const hoverToBlurKey = 'hoverToBlur' diff --git a/desktop/app-settings/readers.ts b/desktop/app-settings/readers.ts index 41f0a07a..d1e5ba42 100644 --- a/desktop/app-settings/readers.ts +++ b/desktop/app-settings/readers.ts @@ -10,6 +10,7 @@ import { codeModelKey, codeThinkingLevelKey, composerStreamingBehaviorKey, + devUpdateBranchKey, dictationMaxDurationSecondsKey, dictationModelIdKey, favoriteFoldersKey, @@ -23,6 +24,7 @@ import { hoverToFocusKey, howcodeNativeAskQuestionsKey, initializeGitOnProjectCreateKey, + legacyDevUpdateBranchKey, piTuiTakeoverKey, preferredProjectLocationKey, projectDeletionModeKey, @@ -68,6 +70,14 @@ function getDictationMaxDurationSeconds(valueJson: string | undefined) { ) } +function getDevUpdateBranch(valueJson: string | undefined) { + return parseBooleanPreference(valueJson) ?? false +} + +function getDevUpdateBranchValue(value: (key: string) => string | undefined) { + return value(devUpdateBranchKey) ?? value(legacyDevUpdateBranchKey) +} + export function loadAppSettings(): AppSettings { const rows = loadPreferenceRows() const value = (key: string) => rows.get(key)?.valueJson @@ -107,6 +117,7 @@ export function loadAppSettings(): AppSettings { parseProjectDeletionModePreference(value(projectDeletionModeKey)) ?? 'pi-only', useAgentsSkillsPaths: parseBooleanPreference(value(useAgentsSkillsPathsKey)) ?? false, howcodeNativeAskQuestions: parseBooleanPreference(value(howcodeNativeAskQuestionsKey)) ?? false, + devUpdateBranch: getDevUpdateBranch(getDevUpdateBranchValue(value)), piTuiTakeover: parseBooleanPreference(value(piTuiTakeoverKey)) ?? false, hoverToFocus: parseBooleanPreference(value(hoverToFocusKey)) ?? true, hoverToBlur: parseBooleanPreference(value(hoverToBlurKey)) ?? false, diff --git a/desktop/app-settings/writers.ts b/desktop/app-settings/writers.ts index 269c959e..d440b9ee 100644 --- a/desktop/app-settings/writers.ts +++ b/desktop/app-settings/writers.ts @@ -19,6 +19,7 @@ import { codeModelKey, codeThinkingLevelKey, composerStreamingBehaviorKey, + devUpdateBranchKey, dictationMaxDurationSecondsKey, dictationModelIdKey, favoriteFoldersKey, @@ -249,6 +250,10 @@ export function setHowcodeNativeAskQuestions(enabled: boolean) { writeAppPreference(howcodeNativeAskQuestionsKey, JSON.stringify(enabled)) } +export function setDevUpdateBranch(enabled: boolean) { + writeAppPreference(devUpdateBranchKey, JSON.stringify(enabled)) +} + export function setPiTuiTakeover(enabled: boolean) { writeAppPreference(piTuiTakeoverKey, JSON.stringify(enabled)) } diff --git a/desktop/pi-desktop-runtime.ts b/desktop/pi-desktop-runtime.ts index c05796b6..0f233838 100644 --- a/desktop/pi-desktop-runtime.ts +++ b/desktop/pi-desktop-runtime.ts @@ -28,7 +28,7 @@ import { upsertThreadSummary, } from './thread-state-db.ts' -export { getLiveThread } +export { getLiveThread, loadAppSettings } function withComposerModeSettings( request: TRequest, diff --git a/desktop/pi-threads.ts b/desktop/pi-threads.ts index 50a53cdd..738cff89 100644 --- a/desktop/pi-threads.ts +++ b/desktop/pi-threads.ts @@ -1,3 +1,4 @@ +export { loadAppSettings } from './app-settings/readers.ts' export { compileReactArtifact } from './artifact-compiler.ts' export { editArtifact, diff --git a/desktop/pi-threads/project-usage-summary.ts b/desktop/pi-threads/project-usage-summary.ts index 7b874412..8cac9255 100644 --- a/desktop/pi-threads/project-usage-summary.ts +++ b/desktop/pi-threads/project-usage-summary.ts @@ -24,6 +24,8 @@ type UsageEntry = { const PROJECT_USAGE_SCAN_CONCURRENCY = 6 const TOP_USAGE_SESSION_LIMIT = 10 +const ARCHIVED_USAGE_CACHE_LIMIT = 100 +const THREAD_USAGE_CACHE_LIMIT = 2000 type UsageTotals = Pick< ProjectUsageSummary, @@ -50,6 +52,25 @@ type ThreadUsageCacheEntry = { const archivedUsageCache = new Map() const threadUsageCache = new Map() +function pruneCache(cache: Map, limit: number) { + while (cache.size > limit) { + const oldestKey = cache.keys().next().value + if (oldestKey === undefined) return + cache.delete(oldestKey) + } +} + +function setBoundedCacheEntry( + cache: Map, + key: TKey, + value: TValue, + limit: number, +) { + if (cache.has(key)) cache.delete(key) + cache.set(key, value) + pruneCache(cache, limit) +} + function finiteNumber(value: number | undefined) { return typeof value === 'number' && Number.isFinite(value) ? value : 0 } @@ -189,7 +210,12 @@ async function summarizeThreads(projectId: string, threads: ReturnType { - archivedUsageCache.set(projectId, { summary, threadSignature, promise: null }) + setBoundedCacheEntry( + archivedUsageCache, + projectId, + { summary, threadSignature, promise: null }, + ARCHIVED_USAGE_CACHE_LIMIT, + ) return summary }) .catch((error) => { console.warn(`Failed to summarize archived project usage for ${projectId}.`, error) - archivedUsageCache.set(projectId, { - summary: cached?.summary ?? null, - threadSignature, - promise: null, - }) + setBoundedCacheEntry( + archivedUsageCache, + projectId, + { + summary: cached?.summary ?? null, + threadSignature, + promise: null, + }, + ARCHIVED_USAGE_CACHE_LIMIT, + ) return cached?.summary ?? summarizeEmptyProject(projectId) }) - archivedUsageCache.set(projectId, { - summary: cached?.summary ?? null, - threadSignature, - promise, - }) + setBoundedCacheEntry( + archivedUsageCache, + projectId, + { + summary: cached?.summary ?? null, + threadSignature, + promise, + }, + ARCHIVED_USAGE_CACHE_LIMIT, + ) return { summary: cached?.summary ?? null, refreshing: true } } diff --git a/desktop/pi-threads/settings-actions.ts b/desktop/pi-threads/settings-actions.ts index 2709797e..42251236 100644 --- a/desktop/pi-threads/settings-actions.ts +++ b/desktop/pi-threads/settings-actions.ts @@ -25,6 +25,7 @@ import { setCodeModelSelection, setCodeThinkingLevel, setComposerStreamingBehavior, + setDevUpdateBranch, setDictationMaxDurationSeconds, setDictationModelId, setFavoriteFolders, @@ -139,6 +140,8 @@ const settingsUpdateHandlers = { setUseAgentsSkillsPaths(getSettingsBooleanValue(payload) ?? false), howcodeNativeAskQuestions: (payload) => setHowcodeNativeAskQuestions(getSettingsBooleanValue(payload) ?? false), + devUpdateBranch: (payload) => setDevUpdateBranch(getSettingsBooleanValue(payload) ?? false), + betaUpdateBranch: (payload) => setDevUpdateBranch(getSettingsBooleanValue(payload) ?? false), piTuiTakeover: (payload) => setPiTuiTakeover(getSettingsBooleanValue(payload) ?? false), hoverToFocus: (payload) => setHoverToFocus(getSettingsBooleanValue(payload) ?? true), hoverToBlur: (payload) => setHoverToBlur(getSettingsBooleanValue(payload) ?? false), diff --git a/desktop/pi-threads/thread-actions.ts b/desktop/pi-threads/thread-actions.ts index 4eb8534b..3129aa8b 100644 --- a/desktop/pi-threads/thread-actions.ts +++ b/desktop/pi-threads/thread-actions.ts @@ -13,6 +13,7 @@ import { openThreadRuntime, startNewThread } from '../pi-desktop-runtime.ts' import { archiveThread, archiveThreads, + clearReadInboxThreads, deleteThreadRecord, dismissInboxThread, getThreadSessionPath, @@ -138,6 +139,15 @@ const threadActionHandlers = { if (sessionPath) dismissInboxThread(sessionPath) return handledAction() }, + 'inbox.clear-read': (payload) => { + const olderThanDays = + typeof payload.olderThanDays === 'number' && Number.isFinite(payload.olderThanDays) + ? Math.max(0, payload.olderThanDays) + : null + const olderThanMs = + olderThanDays === null ? null : Date.now() - olderThanDays * 24 * 60 * 60 * 1000 + return handledAction({ clearedCount: clearReadInboxThreads(olderThanMs) }) + }, } satisfies Partial> export async function handleThreadDesktopAction( diff --git a/desktop/project-create.test.ts b/desktop/project-create.test.ts new file mode 100644 index 00000000..62a79304 --- /dev/null +++ b/desktop/project-create.test.ts @@ -0,0 +1,104 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callOrder: string[] = [] +const startNewThreadMock = vi.fn(async (request: { projectId?: string }) => { + callOrder.push(`start:${request.projectId ?? ''}`) + return { + projectId: request.projectId, + sessionPath: `${request.projectId ?? 'missing'}/.pi/sessions/draft.jsonl`, + threadId: 'draft-thread', + } +}) +const ensureProjectMock = vi.fn((projectId: string) => { + callOrder.push(`ensure:${projectId}`) +}) +const moveProjectToTopMock = vi.fn((projectId: string) => { + callOrder.push(`top:${projectId}`) +}) +const setProjectRepoOriginMock = vi.fn() + +vi.mock('./pi-desktop-runtime.ts', () => ({ + startNewThread: startNewThreadMock, +})) + +vi.mock('./thread-state-db.ts', () => ({ + ensureProject: ensureProjectMock, + listProjects: vi.fn(() => []), + moveProjectToTop: moveProjectToTopMock, + setProjectRepoOrigin: setProjectRepoOriginMock, +})) + +describe('project creation', () => { + let workspacePath: string + + beforeEach(async () => { + callOrder.length = 0 + startNewThreadMock.mockClear() + ensureProjectMock.mockClear() + moveProjectToTopMock.mockClear() + setProjectRepoOriginMock.mockClear() + workspacePath = await mkdtemp(path.join(os.tmpdir(), 'howcode-project-create-workspace-')) + }) + + afterEach(async () => { + await rm(workspacePath, { recursive: true, force: true }) + }) + + it('creates and orders a plain new project after starting its draft thread', async () => { + const { createProject } = await import('./project-create.ts') + + const result = await createProject({ + preferredProjectLocation: workspacePath, + projectName: 'Fresh Project', + initializeGit: false, + }) + + const projectPath = path.join(workspacePath, 'Fresh Project') + expect(result.projectId).toBe(projectPath) + expect(callOrder).toEqual([ + `start:${projectPath}`, + `ensure:${projectPath}`, + `top:${projectPath}`, + ]) + }) + + it('adds and orders a folder project after starting its draft thread', async () => { + const { addProjectFromPath } = await import('./project-create.ts') + const projectPath = path.join(workspacePath, 'existing-project') + + const result = await addProjectFromPath({ + projectPath, + createIfMissing: true, + initializeGit: false, + }) + + expect(result.projectId).toBe(projectPath) + expect(callOrder).toEqual([ + `start:${projectPath}`, + `ensure:${projectPath}`, + `top:${projectPath}`, + ]) + }) + + it('does not insert the project row if draft thread startup fails', async () => { + const { createProject } = await import('./project-create.ts') + const brokenProjectPath = path.join(workspacePath, 'Broken Project') + startNewThreadMock.mockImplementationOnce(async (request: { projectId?: string }) => { + callOrder.push(`start:${request.projectId ?? ''}`) + throw new Error('runtime failed') + }) + + await expect( + createProject({ + preferredProjectLocation: workspacePath, + projectName: 'Broken Project', + initializeGit: false, + }), + ).rejects.toThrow('runtime failed') + + expect(callOrder).toEqual([`start:${brokenProjectPath}`]) + }) +}) diff --git a/desktop/project-create.ts b/desktop/project-create.ts index 42c2b359..eb1d08ee 100644 --- a/desktop/project-create.ts +++ b/desktop/project-create.ts @@ -20,6 +20,13 @@ import { const execFile = promisify(execFileCallback) +async function startThreadForNewlyVisibleProject(projectId: string) { + const result = await startNewThread({ projectId }) + ensureProject(projectId) + moveProjectToTop(projectId) + return result +} + function sanitizeProjectFolderName(projectName: string) { let nextName = projectName .trim() @@ -59,8 +66,7 @@ export async function createProject(options: { await initializeProjectGit(projectPath) } - const result = await startNewThread({ projectId: projectPath }) - moveProjectToTop(projectPath) + const result = await startThreadForNewlyVisibleProject(projectPath) return result } @@ -90,8 +96,7 @@ export async function addProjectFromPath(options: { await initializeProjectGit(resolvedProjectPath) } - const result = await startNewThread({ projectId: resolvedProjectPath }) - moveProjectToTop(resolvedProjectPath) + const result = await startThreadForNewlyVisibleProject(resolvedProjectPath) return result } @@ -210,9 +215,7 @@ export async function createProjectFromGitHubUrl(options: { throw new Error(`Unable to clone ${repository.canonicalUrl}: ${formatGitCommandError(error)}`) } - const result = await startNewThread({ projectId: projectPath }) - ensureProject(projectPath) + const result = await startThreadForNewlyVisibleProject(projectPath) setProjectRepoOrigin(projectPath, repository.canonicalUrl) - moveProjectToTop(projectPath) return result } diff --git a/desktop/runtime-host/live-runtime-service.ts b/desktop/runtime-host/live-runtime-service.ts index b7fd51d1..0eae846d 100644 --- a/desktop/runtime-host/live-runtime-service.ts +++ b/desktop/runtime-host/live-runtime-service.ts @@ -10,7 +10,6 @@ import type { ComposerThinkingLevel, } from '../../shared/desktop-contracts.ts' import { getDesktopWorkingDirectory } from '../../shared/desktop-working-directory.ts' -import { normalizeModelRegistryContextWindows } from '../../shared/model-context-window-normalization.ts' import { createLocalThreadDraft, getPersistedSessionPath } from '../../shared/session-paths.ts' import { getPiModule } from '../pi-module.ts' import { discoverHeadlessAgentSessionResources } from '../runtime/agent-session-extensions.ts' @@ -281,24 +280,35 @@ export async function setComposerModel( ) { const persistedSessionPath = getPersistedSessionPath(request.sessionPath) if (!persistedSessionPath) { - const { AuthStorage, ModelRegistry, SettingsManager, getAgentDir } = await getPiModule() + const { SettingsManager, getAgentDir } = await getPiModule() const cwd = request.projectId ?? getDesktopWorkingDirectory() const agentDir = getAgentDir() - const authStorage = AuthStorage.create() - const modelRegistry = normalizeModelRegistryContextWindows( - ModelRegistry.create(authStorage, `${agentDir}/models.json`), - ) - const model = modelRegistry.find(provider, modelId) - if (!model) throw new Error(`Unknown Pi model: ${provider}/${modelId}`) - const currentComposer = await buildComposerStateSnapshot({ projectId: cwd, sessionPath: null }) - const settingsManager = SettingsManager.create(cwd, agentDir) - settingsManager.setDefaultModelAndProvider(provider, modelId) - settingsManager.setDefaultThinkingLevel( - clampThinkingLevel( - currentComposer.currentThinkingLevel, - getAvailableThinkingLevelsForModel(model), - ), - ) + const snapshot = await createComposerSnapshotSession({ + ...request, + projectId: cwd, + sessionPath: null, + }) + + try { + const model = snapshot.session.modelRegistry.find(provider, modelId) + if (!model) throw new Error(`Unknown Pi model: ${provider}/${modelId}`) + const currentComposer = await buildComposerStateSnapshot({ + ...request, + projectId: cwd, + sessionPath: null, + }) + const settingsManager = SettingsManager.create(cwd, agentDir) + settingsManager.setDefaultModelAndProvider(provider, modelId) + settingsManager.setDefaultThinkingLevel( + clampThinkingLevel( + currentComposer.currentThinkingLevel, + getAvailableThinkingLevelsForModel(model), + ), + ) + } finally { + snapshot.session.dispose() + } + await emitComposerUpdate({ ...request, sessionPath: null }) return { ok: true as const } } diff --git a/desktop/runtime-host/slash-command-service.ts b/desktop/runtime-host/slash-command-service.ts index fe94be02..37d02bf4 100644 --- a/desktop/runtime-host/slash-command-service.ts +++ b/desktop/runtime-host/slash-command-service.ts @@ -6,7 +6,11 @@ import { import type { ComposerSlashCommand } from '../../shared/desktop-contracts.ts' import type { PiRuntime } from '../runtime/types.ts' -const builtinCommandNames = new Set([compactSlashCommand.name]) +const reservedCommandNames = new Set([ + appSettingsSlashCommand.name, + appNewSessionSlashCommand.name, + compactSlashCommand.name, +]) export function mapSessionCommands(session: PiRuntime['session']): ComposerSlashCommand[] { const commands: ComposerSlashCommand[] = [ @@ -17,7 +21,7 @@ export function mapSessionCommands(session: PiRuntime['session']): ComposerSlash const extensionCommandNames = new Set() for (const command of session.extensionRunner.getRegisteredCommands()) { - if (builtinCommandNames.has(command.invocationName)) { + if (reservedCommandNames.has(command.invocationName)) { continue } @@ -31,7 +35,7 @@ export function mapSessionCommands(session: PiRuntime['session']): ComposerSlash } for (const template of session.promptTemplates) { - if (builtinCommandNames.has(template.name) || extensionCommandNames.has(template.name)) { + if (reservedCommandNames.has(template.name) || extensionCommandNames.has(template.name)) { continue } @@ -47,7 +51,7 @@ export function mapSessionCommands(session: PiRuntime['session']): ComposerSlash for (const skill of session.resourceLoader.getSkills().skills) { const skillCommandName = `skill:${skill.name}` if ( - builtinCommandNames.has(skillCommandName) || + reservedCommandNames.has(skillCommandName) || extensionCommandNames.has(skillCommandName) ) { continue diff --git a/desktop/runtime/composer-service.ts b/desktop/runtime/composer-service.ts index 5cf72f7f..58340ab5 100644 --- a/desktop/runtime/composer-service.ts +++ b/desktop/runtime/composer-service.ts @@ -9,7 +9,6 @@ import type { ComposerThinkingLevel, } from '../../shared/desktop-contracts.ts' import { getDesktopWorkingDirectory } from '../../shared/desktop-working-directory.ts' -import { normalizeModelRegistryContextWindows } from '../../shared/model-context-window-normalization.ts' import { createLocalThreadDraft, getPersistedSessionPath } from '../../shared/session-paths.ts' import { loadAppSettings } from '../app-settings/readers.ts' import { getPiModule } from '../pi-module.ts' @@ -24,6 +23,7 @@ import { buildComposerState, buildComposerStateSnapshot, clampThinkingLevel, + createComposerSnapshotSession, getAvailableThinkingLevelsForModel, } from './composer-state.ts' import { @@ -159,28 +159,43 @@ async function promptAndReturnAfterPreflight({ }) } -async function setDraftComposerModel(cwd: string, provider: string, modelId: string) { - const { AuthStorage, ModelRegistry, SettingsManager, getAgentDir } = await getPiModule() +async function setDraftComposerModel( + request: ComposerStateRequest, + cwd: string, + provider: string, + modelId: string, +) { + const { SettingsManager, getAgentDir } = await getPiModule() const agentDir = getAgentDir() - const authStorage = AuthStorage.create() - const modelRegistry = normalizeModelRegistryContextWindows( - ModelRegistry.create(authStorage, `${agentDir}/models.json`), - ) - const model = modelRegistry.find(provider, modelId) + const snapshot = await createComposerSnapshotSession({ + ...request, + projectId: cwd, + sessionPath: null, + }) - if (!model) { - throw new Error(`Unknown Pi model: ${provider}/${modelId}`) - } + try { + const model = snapshot.session.modelRegistry.find(provider, modelId) - const currentComposer = await buildComposerStateSnapshot({ projectId: cwd, sessionPath: null }) - const nextThinkingLevel = clampThinkingLevel( - currentComposer.currentThinkingLevel, - getAvailableThinkingLevelsForModel(model), - ) - const settingsManager = SettingsManager.create(cwd, agentDir) + if (!model) { + throw new Error(`Unknown Pi model: ${provider}/${modelId}`) + } - settingsManager.setDefaultModelAndProvider(provider, modelId) - settingsManager.setDefaultThinkingLevel(nextThinkingLevel) + const currentComposer = await buildComposerStateSnapshot({ + ...request, + projectId: cwd, + sessionPath: null, + }) + const nextThinkingLevel = clampThinkingLevel( + currentComposer.currentThinkingLevel, + getAvailableThinkingLevelsForModel(model), + ) + const settingsManager = SettingsManager.create(cwd, agentDir) + + settingsManager.setDefaultModelAndProvider(provider, modelId) + settingsManager.setDefaultThinkingLevel(nextThinkingLevel) + } finally { + snapshot.session.dispose() + } } async function setDraftComposerThinkingLevel(cwd: string, level: ComposerThinkingLevel) { @@ -223,6 +238,7 @@ export async function setComposerModel( if (!persistedSessionPath) { await setDraftComposerModel( + request, request.projectId ?? getDesktopWorkingDirectory(), provider, modelId, diff --git a/desktop/runtime/slash-commands.ts b/desktop/runtime/slash-commands.ts index 07ecd63a..b145e896 100644 --- a/desktop/runtime/slash-commands.ts +++ b/desktop/runtime/slash-commands.ts @@ -15,7 +15,11 @@ import { } from './runtime-registry.ts' import type { PiRuntime } from './types.ts' -const builtinCommandNames = new Set([compactSlashCommand.name]) +const reservedCommandNames = new Set([ + appSettingsSlashCommand.name, + appNewSessionSlashCommand.name, + compactSlashCommand.name, +]) function mapSessionCommands(session: PiRuntime['session']): ComposerSlashCommand[] { const commands: ComposerSlashCommand[] = [ @@ -26,7 +30,7 @@ function mapSessionCommands(session: PiRuntime['session']): ComposerSlashCommand const extensionCommandNames = new Set() for (const command of session.extensionRunner.getRegisteredCommands()) { - if (builtinCommandNames.has(command.invocationName)) { + if (reservedCommandNames.has(command.invocationName)) { continue } @@ -40,7 +44,7 @@ function mapSessionCommands(session: PiRuntime['session']): ComposerSlashCommand } for (const template of session.promptTemplates) { - if (builtinCommandNames.has(template.name) || extensionCommandNames.has(template.name)) { + if (reservedCommandNames.has(template.name) || extensionCommandNames.has(template.name)) { continue } @@ -56,7 +60,7 @@ function mapSessionCommands(session: PiRuntime['session']): ComposerSlashCommand for (const skill of session.resourceLoader.getSkills().skills) { const skillCommandName = `skill:${skill.name}` if ( - builtinCommandNames.has(skillCommandName) || + reservedCommandNames.has(skillCommandName) || extensionCommandNames.has(skillCommandName) ) { continue diff --git a/desktop/terminal/manager.ts b/desktop/terminal/manager.ts index 0cb5db6a..644316f5 100644 --- a/desktop/terminal/manager.ts +++ b/desktop/terminal/manager.ts @@ -1,4 +1,4 @@ -import { rmSync } from 'node:fs' +import { renameSync, rmSync } from 'node:fs' import { stat } from 'node:fs/promises' import { getPersistedSessionPath } from '../../shared/session-paths.ts' import type { @@ -108,6 +108,70 @@ function ensureProcessStarted(record: TerminalSessionRecord, reason: 'started' | return record.restartPromise } +function findUnboundProjectShellTerminal(request: TerminalOpenRequest) { + if (!request.sessionPath) { + return null + } + + const cwd = request.cwd ?? request.projectId + return ( + listTerminalSessions().find( + (record) => + record.snapshot.projectId === request.projectId && + record.snapshot.sessionPath === null && + record.snapshot.cwd === cwd && + record.snapshot.launchMode === (request.launchMode ?? 'shell'), + ) ?? null + ) +} + +function moveTranscript(fromPath: string, toPath: string) { + if (fromPath === toPath) { + return true + } + + try { + renameSync(fromPath, toPath) + return true + } catch { + // The transcript may not have been flushed yet, or the target can already exist from a + // previous bound terminal. Keeping the live in-memory history is more important than failing + // the bind operation for best-effort persistence. + return false + } +} + +function bindProjectTerminalToSession(input: { + record: TerminalSessionRecord + request: TerminalOpenRequest + sessionId: string +}) { + const previousSessionId = input.record.snapshot.sessionId + const nextTranscriptPath = getTranscriptPath(input.sessionId) + + deleteTerminalSession(previousSessionId) + if (moveTranscript(input.record.transcriptPath, nextTranscriptPath)) { + input.record.transcriptPath = nextTranscriptPath + } + input.record.snapshot = { + ...input.record.snapshot, + sessionId: input.sessionId, + sessionPath: input.request.sessionPath ?? null, + cols: input.request.cols, + rows: input.request.rows, + updatedAt: nowIso(), + } + setTerminalSession(input.sessionId, input.record) + input.record.process?.resize(input.request.cols, input.request.rows) + emitTerminalEvent({ + type: 'updated', + sessionId: input.sessionId, + snapshot: input.record.snapshot, + createdAt: nowIso(), + }) + return input.record.snapshot +} + export async function openTerminal(request: TerminalOpenRequest): Promise { const cwd = request.cwd ?? request.projectId const sessionId = makeSessionId(request) @@ -137,6 +201,27 @@ export async function openTerminal(request: TerminalOpenRequest): Promise= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + + const precision = unitIndex === 0 || value >= 10 ? 0 : 1 + return `${value.toFixed(precision)} ${units[unitIndex]}` +} + +function createDownloadProgressStream(input) { + let downloadedBytes = 0 + let lastLoggedAt = 0 + + return new Transform({ + transform(chunk, _encoding, callback) { + downloadedBytes += chunk.length + input.onProgress(downloadedBytes) + + const now = Date.now() + if (now - lastLoggedAt >= 1000) { + lastLoggedAt = now + const downloadedLabel = formatBytes(downloadedBytes) + if (input.totalBytes > 0) { + const percent = Math.min(100, (downloadedBytes / input.totalBytes) * 100) + const totalLabel = formatBytes(input.totalBytes) + process.stdout.write( + `\rDownloading ${APP_NAME}: ${downloadedLabel} / ${totalLabel} (${percent.toFixed(0)}%)`, + ) + } else { + process.stdout.write(`\rDownloading ${APP_NAME}: ${downloadedLabel}`) + } + } + + callback(null, chunk) + }, + }) +} + async function fetchJson(url) { const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 5000) + const timeout = setTimeout(() => controller.abort(), FETCH_METADATA_TIMEOUT_MS) try { const response = await fetch(url, { signal: controller.signal }) @@ -251,9 +323,21 @@ async function fetchJson(url) { } } -async function downloadFile(url, filePath, timeoutMs = DOWNLOAD_TIMEOUT_MS) { +async function downloadFile(url, filePath, idleTimeoutMs = DOWNLOAD_IDLE_TIMEOUT_MS) { const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), timeoutMs) + let timedOut = false + let idleTimeout = setTimeout(() => { + timedOut = true + controller.abort() + }, idleTimeoutMs) + + const resetIdleTimeout = () => { + clearTimeout(idleTimeout) + idleTimeout = setTimeout(() => { + timedOut = true + controller.abort() + }, idleTimeoutMs) + } try { const response = await fetch(url, { signal: controller.signal }) @@ -261,10 +345,22 @@ async function downloadFile(url, filePath, timeoutMs = DOWNLOAD_TIMEOUT_MS) { throw new Error(`HTTP ${response.status} while downloading ${url}`) } + const totalBytes = Number(response.headers.get('content-length')) || 0 + resetIdleTimeout() await fsp.mkdir(path.dirname(filePath), { recursive: true }) - await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(filePath)) + await pipeline( + Readable.fromWeb(response.body), + createDownloadProgressStream({ totalBytes, onProgress: resetIdleTimeout }), + fs.createWriteStream(filePath), + ) + process.stdout.write('\n') + } catch (error) { + if (timedOut) { + throw new Error(`Download stalled for ${Math.round(idleTimeoutMs / 1000)} seconds: ${url}`) + } + throw error } finally { - clearTimeout(timeout) + clearTimeout(idleTimeout) } } @@ -275,16 +371,22 @@ async function sha256File(filePath) { } async function resolveLatestRelease(target) { - const updateUrl = `${RELEASE_BASE_URL}/stable-${target.os}-${target.arch}-update.json` + const channel = getReleaseChannel() + const releaseBaseUrl = getReleaseBaseUrl(channel) + const updateUrl = `${releaseBaseUrl}/stable-${target.os}-${target.arch}-update.json` const metadata = await fetchJson(updateUrl) if (!metadata || typeof metadata.version !== 'string' || typeof metadata.hash !== 'string') { throw new Error(`Invalid release metadata from ${updateUrl}`) } return { + channel, version: metadata.version, hash: metadata.hash, - assetUrl: `${RELEASE_BASE_URL}/${APP_NAME}-${target.os}-${target.arch}.tar.gz`, + assetUrl: + typeof metadata.assetUrl === 'string' && metadata.assetUrl.length > 0 + ? metadata.assetUrl + : `${releaseBaseUrl}/${APP_NAME}-${target.os}-${target.arch}.tar.gz`, } } @@ -330,6 +432,7 @@ async function installRelease(target, releaseInfo, paths) { JSON.stringify( { version: releaseInfo.version, + channel: releaseInfo.channel, hash: releaseInfo.hash, installDir: paths.installDir, executablePath: paths.executablePath, @@ -340,8 +443,22 @@ async function installRelease(target, releaseInfo, paths) { ) } +async function getPruneKeepDirs(cacheRoot, keepDir) { + const keepDirs = new Set([keepDir]) + + for (const channel of Object.keys(CHANNEL_RELEASE_TAGS)) { + const record = readJsonIfPresent(path.join(cacheRoot, `current-${channel}.json`)) + if (record?.installDir) { + keepDirs.add(record.installDir) + } + } + + return keepDirs +} + async function pruneOldVersions(cacheRoot, keepDir) { const versionsRoot = path.join(cacheRoot, 'versions') + const keepDirs = await getPruneKeepDirs(cacheRoot, keepDir) let entries = [] try { @@ -354,7 +471,7 @@ async function pruneOldVersions(cacheRoot, keepDir) { entries .filter((entry) => entry.isDirectory()) .map((entry) => path.join(versionsRoot, entry.name)) - .filter((dirPath) => dirPath !== keepDir) + .filter((dirPath) => !keepDirs.has(dirPath)) .map((dirPath) => fsp.rm(dirPath, { recursive: true, force: true })), ) } @@ -387,7 +504,8 @@ async function main() { const cacheRoot = getCacheRoot() await fsp.mkdir(cacheRoot, { recursive: true }) - const current = readJsonIfPresent(path.join(cacheRoot, 'current.json')) + const channel = getReleaseChannel() + const current = readJsonIfPresent(path.join(cacheRoot, `current-${channel}.json`)) let releaseInfo = null try { @@ -396,7 +514,7 @@ async function main() { if (current?.executablePath) { const currentPaths = { cacheRoot, - currentFile: path.join(cacheRoot, 'current.json'), + currentFile: path.join(cacheRoot, `current-${channel}.json`), windowsCommandFile: path.join(cacheRoot, `${APP_NAME}.cmd`), installDir: current.installDir || path.dirname(path.dirname(current.executablePath)), launcherWorkingDirectory: path.dirname(current.executablePath), diff --git a/packages/howcode/package.json b/packages/howcode/package.json index 40777b6c..026ae6ec 100644 --- a/packages/howcode/package.json +++ b/packages/howcode/package.json @@ -1,6 +1,6 @@ { "name": "howcode", - "version": "0.1.64", + "version": "0.1.67", "description": "Desktop coding app for Pi with projects, terminal, git, and diff workflows.", "license": "MIT", "author": "Igor Warzocha", @@ -38,6 +38,7 @@ ], "howcode": { "appName": "howcode", - "releaseBaseUrl": "https://github.com/IgorWarzocha/howcode/releases/download/main" + "releaseChannel": "main", + "releaseBaseUrl": "https://github.com/IgorWarzocha/howcode/releases/download/channel-main" } } diff --git a/pages/src/main.tsx b/pages/src/main.tsx index 76444de2..6d981f88 100644 --- a/pages/src/main.tsx +++ b/pages/src/main.tsx @@ -1,6 +1,7 @@ import { Github, Heart } from 'lucide-react' -import { StrictMode, useEffect, useState } from 'react' +import { StrictMode, useEffect, useMemo, useState } from 'react' import { createRoot } from 'react-dom/client' +import changelogMarkdown from '../../docs/changelog.md?raw' import './styles.css' const asset = (path: string) => `${import.meta.env.BASE_URL}${path}` @@ -50,28 +51,46 @@ const installCommands = [ { label: 'global install', command: 'npm i -g howcode' }, ] +const changelogHeadingPattern = /^###\s+(.+)$/ + function copyCommand(command: string) { void navigator.clipboard?.writeText(command) } -const changelog = [ - '0.1.61-6x hotfixes: ASAR packaging, launcher installs, runtime host deps, artifact previews', - '0.1.6 added responsive layouts everywhere-ish', - 'composer now has @ file mentions and $skill mentions', - 'hardened Chat mode filesystem and extensions guardrails', - 'added a custom Chat mode system prompt and scrollable composer input', - 'Git errors are more visible now; please report any', - 'terminal is back on xterm, because addon-fit', - 'ASAR is back, TS6 is fully implemented, and CI is stricter', - 'now on @earendil-works packages. RIP', - 'https://igorwarzocha.github.io/howcode/ is live', - '0.1.5 added Howcode and Pi JSON theme support', -] +function getLatestChangelog(markdown: string) { + const lines = markdown.split('\n') + const headingIndex = lines.findIndex((line) => changelogHeadingPattern.test(line)) + if (headingIndex < 0) { + return { version: 'latest', items: ['See the changelog for recent fixes and shipped bits.'] } + } + + const version = lines[headingIndex]?.match(changelogHeadingPattern)?.[1]?.trim() ?? 'current' + const nextHeadingIndex = lines.findIndex( + (line, index) => index > headingIndex && changelogHeadingPattern.test(line), + ) + const sectionLines = lines.slice( + headingIndex + 1, + nextHeadingIndex > headingIndex ? nextHeadingIndex : undefined, + ) + const items: string[] = [] + for (const line of sectionLines) { + const trimmedLine = line.trim() + if (trimmedLine.startsWith('- ')) items.push(trimmedLine.slice(2)) + } + + return { version, items } +} + +const changelog = getLatestChangelog(changelogMarkdown) function App() { const [activeScreenshot, setActiveScreenshot] = useState<(typeof screenshots)[number] | null>( null, ) + const activeScreenshotIndex = useMemo( + () => screenshots.findIndex((screenshot) => screenshot.id === activeScreenshot?.id), + [activeScreenshot], + ) useEffect(() => { if (!activeScreenshot) { @@ -81,12 +100,24 @@ function App() { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setActiveScreenshot(null) + return + } + + if (event.key === 'ArrowLeft' || event.key.toLowerCase() === 'l') { + const previousIndex = (activeScreenshotIndex - 1 + screenshots.length) % screenshots.length + setActiveScreenshot(screenshots[previousIndex] ?? null) + return + } + + if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'r') { + const nextIndex = (activeScreenshotIndex + 1) % screenshots.length + setActiveScreenshot(screenshots[nextIndex] ?? null) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [activeScreenshot]) + }, [activeScreenshot, activeScreenshotIndex]) return (
@@ -219,6 +250,15 @@ function App() { The launcher downloads the right desktop build and relaunches the cached app. Releases include a Windows installer and a Linux AppImage. Mac should work. Hopefully.

+ Report a weird case → @@ -239,11 +279,11 @@ function App() {
-

latest

+

{changelog.version}

Recent fixes and shipped bits.

    - {changelog.map((item) => ( + {changelog.items.map((item) => (
  1. {item}
  2. ))}
diff --git a/pages/src/styles.css b/pages/src/styles.css index 0a24522a..ad5e76d9 100644 --- a/pages/src/styles.css +++ b/pages/src/styles.css @@ -447,6 +447,48 @@ li { gap: var(--space-xl); } +.install-command { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-lg); + width: min(100%, 360px); + min-height: 48px; + padding: 0 var(--space-lg); + border: 0; + border-radius: 16px; + background: var(--ink); + color: var(--paper); + box-shadow: 0 14px 34px oklch(22% 0.026 272 / 16%); + cursor: copy; + transition-property: transform, background-color; + transition-duration: 170ms; + transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1); +} + +.install-command:hover { + background: oklch(28% 0.032 272); +} + +.install-command:active { + transform: scale(0.96); +} + +.install-command:focus-visible { + outline: 2px solid oklch(58% 0.09 285 / 62%); + outline-offset: 4px; +} + +.install-command code { + color: inherit; +} + +.install-command span { + color: oklch(84% 0.026 272); + font-family: var(--font-mono); + font-size: 0.74rem; +} + .text-link, footer a { color: var(--ink); diff --git a/pages/src/vite-env.d.ts b/pages/src/vite-env.d.ts new file mode 100644 index 00000000..286f0d2d --- /dev/null +++ b/pages/src/vite-env.d.ts @@ -0,0 +1,4 @@ +declare module '*.md?raw' { + const content: string + export default content +} diff --git a/react-doctor.config.json b/react-doctor.config.json new file mode 100644 index 00000000..8cc35f2e --- /dev/null +++ b/react-doctor.config.json @@ -0,0 +1,27 @@ +{ + "ignore": { + "files": ["Frameworks/effect-smol/**", "Frameworks/t3-code/**"], + "overrides": [ + { + "files": [ + "src/app/components/workspace/terminal/terminal-viewport.tsx", + "src/app/components/workspace/composer/composer-text-field.tsx", + "src/app/views/settings-view.tsx", + "src/app/components/workspace/composer/ask-questions-card.tsx" + ], + "rules": ["react-doctor/rerender-state-only-in-handlers"] + }, + { + "files": ["src/app/components/sidebar/projects/sidebar-projects-folder-browser.tsx"], + "rules": ["react-doctor/async-defer-await"] + }, + { + "files": [ + "src/app/views/settings-view.tsx", + "src/app/components/workspace/composer/ask-questions-card.tsx" + ], + "rules": ["react-doctor/no-derived-state-effect"] + } + ] + } +} diff --git a/scripts/dev-web-bridge-node.ts b/scripts/dev-web-bridge-node.ts index ffc321b7..c5648351 100644 --- a/scripts/dev-web-bridge-node.ts +++ b/scripts/dev-web-bridge-node.ts @@ -38,6 +38,7 @@ const devAppUpdateState = { status: 'up-to-date' as const, currentVersion: packageJson.version, latestVersion: packageJson.version, + channel: null, error: null, } @@ -83,7 +84,8 @@ async function writeUniqueTextFile(directoryPath: string, fileName: string, cont async function listProjectDirectoryEntries(request: { path?: string | null | undefined }) { const homePath = os.homedir() - const requestedPath = request.path?.trim() ? request.path : homePath + const trimmedRequestPath = request.path?.trim() ?? '' + const requestedPath = trimmedRequestPath || homePath const currentPath = await realpath(path.resolve(requestedPath)).catch(() => path.resolve(requestedPath), ) diff --git a/scripts/package-launcher-artifacts.ts b/scripts/package-launcher-artifacts.ts index 10adf8d9..f2666b59 100644 --- a/scripts/package-launcher-artifacts.ts +++ b/scripts/package-launcher-artifacts.ts @@ -4,7 +4,7 @@ const linuxUnpackedDirectoryPattern = /linux.*unpacked$/i import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, mkdirSync } from 'node:fs' -import { cp, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { copyFile, cp, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -164,6 +164,12 @@ async function createUpdateMetadata(archivePath: string, target: Target, version const archiveBuffer = await readFile(archivePath) const hash = createHash('sha256').update(archiveBuffer).digest('hex') const metadataPath = path.join(artifactRoot, `stable-${target.os}-${target.arch}-update.json`) + const immutableArchivePath = path.join( + path.dirname(archivePath), + `archive-${appName}-${target.os}-${target.arch}-${hash}.tar.gz`, + ) + await copyFile(archivePath, immutableArchivePath) + const { HOWCODE_RELEASE_ASSET_BASE_URL: assetBaseUrl } = process.env await writeFile( metadataPath, @@ -171,11 +177,16 @@ async function createUpdateMetadata(archivePath: string, target: Target, version { version, hash, + assetUrl: assetBaseUrl + ? `${assetBaseUrl}/${path.basename(immutableArchivePath)}` + : undefined, }, null, 2, ), ) + + return immutableArchivePath } async function main() { @@ -186,8 +197,8 @@ async function main() { const target = getCurrentTarget() const bundlePath = await resolveBundlePath(target) const archivePath = await createNormalizedArchive(bundlePath, target) - await createUpdateMetadata(archivePath, target, packageJson.version) - console.log(`created ${path.relative(process.cwd(), archivePath)}`) + const immutableArchivePath = await createUpdateMetadata(archivePath, target, packageJson.version) + console.log(`created ${path.relative(process.cwd(), immutableArchivePath)}`) } void main().catch((error) => { diff --git a/shared/desktop-action-contracts.ts b/shared/desktop-action-contracts.ts index 3d0f3cb7..4fc8be14 100644 --- a/shared/desktop-action-contracts.ts +++ b/shared/desktop-action-contracts.ts @@ -33,6 +33,7 @@ export type DesktopActionPayloadFields = { level?: ComposerThinkingLevel message?: string | undefined | null | undefined modelId?: string | undefined + olderThanDays?: number | undefined | null | undefined preview?: boolean | undefined projectId?: string | undefined | null | undefined projectIds?: string[] | undefined @@ -92,6 +93,8 @@ export type DesktopSettingsUpdatePayload = | { key: 'projectDeletionMode'; value: ProjectDeletionMode } | { key: 'useAgentsSkillsPaths'; value: boolean } | { key: 'howcodeNativeAskQuestions'; value: boolean } + | { key: 'devUpdateBranch'; value: boolean } + | { key: 'betaUpdateBranch'; value: boolean } | { key: 'piTuiTakeover'; value: boolean } | { key: 'hoverToFocus'; value: boolean } | { key: 'hoverToBlur'; value: boolean } @@ -211,6 +214,7 @@ export type DesktopActionPayloadMap = { } 'inbox.mark-read': { sessionPath: string; projectId?: string | undefined | null | undefined } 'inbox.dismiss': { sessionPath: string; projectId?: string | undefined | null | undefined } + 'inbox.clear-read': { olderThanDays?: number | undefined | null | undefined } 'settings.update': DesktopSettingsUpdatePayload 'settings.clear-clipboard-images': EmptyActionPayload 'pi-settings.update': { piSettingsKey: keyof PiSettings; value: string | number | boolean } diff --git a/shared/desktop-action-coverage.ts b/shared/desktop-action-coverage.ts index 71e38cf6..5d2b559e 100644 --- a/shared/desktop-action-coverage.ts +++ b/shared/desktop-action-coverage.ts @@ -31,6 +31,7 @@ export const implementedDesktopActions = [ 'composer.answer-native-questions', 'inbox.mark-read', 'inbox.dismiss', + 'inbox.clear-read', 'workspace.commit', 'workspace.commit-options', 'workspace.diff-preferences', diff --git a/shared/desktop-actions.ts b/shared/desktop-actions.ts index 2bb31a77..ab825a9e 100644 --- a/shared/desktop-actions.ts +++ b/shared/desktop-actions.ts @@ -37,6 +37,7 @@ export const desktopActions = [ 'composer.answer-native-questions', 'inbox.mark-read', 'inbox.dismiss', + 'inbox.clear-read', 'settings.update', 'settings.clear-clipboard-images', 'pi-settings.update', diff --git a/shared/desktop-app-update-contracts.ts b/shared/desktop-app-update-contracts.ts index 2b65923a..23f695c1 100644 --- a/shared/desktop-app-update-contracts.ts +++ b/shared/desktop-app-update-contracts.ts @@ -13,5 +13,6 @@ export type AppUpdateState = { status: AppUpdateStatus currentVersion: string latestVersion: string | null + channel: 'main' | 'dev' | null error: string | null } diff --git a/shared/desktop-settings-contracts.ts b/shared/desktop-settings-contracts.ts index ef1e1a1f..95b49f35 100644 --- a/shared/desktop-settings-contracts.ts +++ b/shared/desktop-settings-contracts.ts @@ -42,6 +42,7 @@ export type AppSettings = { projectDeletionMode: ProjectDeletionMode useAgentsSkillsPaths: boolean howcodeNativeAskQuestions: boolean + devUpdateBranch: boolean piTuiTakeover: boolean hoverToFocus: boolean hoverToBlur: boolean diff --git a/shared/pi-thread-action-payloads.ts b/shared/pi-thread-action-payloads.ts index 9b7db388..9202b36f 100644 --- a/shared/pi-thread-action-payloads.ts +++ b/shared/pi-thread-action-payloads.ts @@ -273,6 +273,8 @@ export function getSettingsKey(payload: DesktopActionPayloadInput) { payload.key === 'projectDeletionMode' || payload.key === 'useAgentsSkillsPaths' || payload.key === 'howcodeNativeAskQuestions' || + payload.key === 'devUpdateBranch' || + payload.key === 'betaUpdateBranch' || payload.key === 'piTuiTakeover' || payload.key === 'hoverToFocus' || payload.key === 'hoverToBlur' diff --git a/shared/thread-history.ts b/shared/thread-history.ts index 9cf63083..5c7c0c7b 100644 --- a/shared/thread-history.ts +++ b/shared/thread-history.ts @@ -17,13 +17,13 @@ export type SessionPathEntry = { } function isDisplayableEntry(entry: SessionPathEntry) { - return ( - entry.type === 'message' || - entry.type === 'custom_message' || - entry.type === 'branch_summary' || - entry.type === 'model_change' || - entry.type === 'thinking_level_change' - ) + if (entry.display === false) return false + if (entry.type === 'message') return Boolean(entry.message) + if (entry.type === 'custom_message') return true + if (entry.type === 'branch_summary') return Boolean(entry.summary?.trim()) + if (entry.type === 'model_change') return Boolean(entry.provider?.trim() && entry.modelId?.trim()) + if (entry.type === 'thinking_level_change') return Boolean(entry.thinkingLevel?.trim()) + return false } function modelChangeConsumesNextEntry( @@ -58,6 +58,10 @@ function appendModelChangeMessage( entry: SessionPathEntry, nextEntry: SessionPathEntry | undefined, ) { + if (entry.display === false) { + return false + } + const provider = entry.provider?.trim() const modelId = entry.modelId?.trim() if (!(provider && modelId)) { @@ -81,6 +85,10 @@ function appendModelChangeMessage( } function appendThinkingLevelChangeMessage(messages: AgentMessage[], entry: SessionPathEntry) { + if (entry.display === false) { + return + } + const thinkingLevel = entry.thinkingLevel?.trim() if (!thinkingLevel) { return diff --git a/src/app/app-shell/app-shell-layout.tsx b/src/app/app-shell/app-shell-layout.tsx index b1885cba..e458adcf 100644 --- a/src/app/app-shell/app-shell-layout.tsx +++ b/src/app/app-shell/app-shell-layout.tsx @@ -15,7 +15,6 @@ import type { AppShellController } from './useAppShellController' import { useAppShellLayoutState } from './useAppShellLayoutState' const TERMINAL_DRAWER_WIDTH = 'min(28rem, calc(100% - 2.5rem))' - type AppShellLayoutViewProps = { controller: AppShellController projects: AppShellController['projects'] @@ -242,6 +241,7 @@ const FALLBACK_APP_SETTINGS = { projectDeletionMode: 'pi-only', useAgentsSkillsPaths: false, howcodeNativeAskQuestions: false, + devUpdateBranch: false, piTuiTakeover: false, hoverToFocus: true, hoverToBlur: false, @@ -294,6 +294,7 @@ function AppShellSidebar(props: AppShellLayoutViewProps) { onShowView={handleShowView} onToggleSettings={handleToggleSettings} onOpenExtensionsView={() => handleShowView('extensions')} + onOpenAbout={controller.handleShowLanding} onOpenSkillsView={() => handleShowView('skills')} onOpenSettingsPanel={(target) => handleShowView('settings', target)} onOpenArchivedThreads={() => handleShowView('archived')} @@ -378,6 +379,7 @@ function CompactWorkspaceSidebarButton(props: AppShellLayoutViewProps) { } = props if (!(sidebarCompactMode && !sidebarOverlayOpen && !utilityViewActive) || props.takeoverVisible) return null + if (props.state.activeView === 'code' && props.state.selectedProjectId) return null const closeVisible = artifactDrawerOverlayVisible && closeArtifactDrawerOverlay const workspaceDockStyle = { '--dock-left-lane': 'max(2rem, calc((100cqw - 800px - 1rem) / 2))', @@ -794,7 +796,8 @@ export function AppShellLayout({ controller }: AppShellLayoutProps) { const terminalSessionPath = getThreadSessionPath(state) const activeThreadId = getThreadId(state) const takeoverVisible = state.takeoverVisible - const terminalDrawerVisible = state.activeView === 'thread' && state.terminalVisible + const terminalDrawerVisible = + (state.activeView === 'thread' || state.activeView === 'code') && state.terminalVisible const utilityViewActive = isUtilityView(state.activeView) const compactSidebarButtonEdgeMode = state.activeView === 'code' || terminalDrawerVisible || artifactDrawerOverlayVisible diff --git a/src/app/app-shell/controller-optimistic-updates.ts b/src/app/app-shell/controller-optimistic-updates.ts index 07e15092..c6ea31f8 100644 --- a/src/app/app-shell/controller-optimistic-updates.ts +++ b/src/app/app-shell/controller-optimistic-updates.ts @@ -40,6 +40,8 @@ const optimisticSettingKeys = new Set([ 'projectDeletionMode', 'useAgentsSkillsPaths', 'howcodeNativeAskQuestions', + 'devUpdateBranch', + 'betaUpdateBranch', 'piTuiTakeover', 'hoverToFocus', 'hoverToBlur', @@ -158,6 +160,9 @@ function applyOptimisticBooleanSetting( if (payload.key === 'useAgentsSkillsPaths') nextSettings.useAgentsSkillsPaths = payload.value if (payload.key === 'howcodeNativeAskQuestions') nextSettings.howcodeNativeAskQuestions = payload.value + if (payload.key === 'devUpdateBranch' || payload.key === 'betaUpdateBranch') { + nextSettings.devUpdateBranch = payload.value + } if (payload.key === 'piTuiTakeover') nextSettings.piTuiTakeover = payload.value if (payload.key === 'hoverToFocus') nextSettings.hoverToFocus = payload.value if (payload.key === 'hoverToBlur') nextSettings.hoverToBlur = payload.value diff --git a/src/app/app-shell/controller-post-action-effects.ts b/src/app/app-shell/controller-post-action-effects.ts index 61e53c25..8e528550 100644 --- a/src/app/app-shell/controller-post-action-effects.ts +++ b/src/app/app-shell/controller-post-action-effects.ts @@ -385,7 +385,8 @@ const postEffectHandlers: PostEffectHandler[] = [ matches: (ctx) => ctx.action === 'thread.open' || ctx.action === 'inbox.mark-read' || - ctx.action === 'inbox.dismiss', + ctx.action === 'inbox.dismiss' || + ctx.action === 'inbox.clear-read', run: handleThreadOpenOrInboxEffects, }, { diff --git a/src/app/app-shell/useAppShellCommands.ts b/src/app/app-shell/useAppShellCommands.ts index 20cd8a24..22793221 100644 --- a/src/app/app-shell/useAppShellCommands.ts +++ b/src/app/app-shell/useAppShellCommands.ts @@ -81,6 +81,10 @@ export function useAppShellCommands({ dispatch({ type: 'show-view', view }) } + const handleShowLanding = () => { + dispatch({ type: 'show-landing' }) + } + const handleCloseUtilityView = () => { dispatch({ type: 'close-utility-view' }) } @@ -224,6 +228,7 @@ export function useAppShellCommands({ handleSelectInboxThread, handleShowTakeoverTerminal, handleShowView, + handleShowLanding, handleThreadOpen, handleToggleProjectCollapse, handleToggleSettings: () => dispatch({ type: 'toggle-settings' }), diff --git a/src/app/app-shell/useDesktopActionHandlers.ts b/src/app/app-shell/useDesktopActionHandlers.ts index 7b5c690d..34a19a7c 100644 --- a/src/app/app-shell/useDesktopActionHandlers.ts +++ b/src/app/app-shell/useDesktopActionHandlers.ts @@ -13,6 +13,7 @@ import type { ProjectGitState, ThreadData, } from '../desktop/types' +import { checkAppUpdateQuery } from '../query/desktop-query' import type { WorkspaceAction, WorkspaceState } from '../state/workspace' import type { View } from '../types' import { buildContextualActionPayload } from './controller-action-helpers' @@ -165,6 +166,15 @@ export function useDesktopActionHandlers({ showToast(actionErrorMessage) } + if ( + action === 'settings.update' && + (contextualPayload.key === 'devUpdateBranch' || + contextualPayload.key === 'betaUpdateBranch') && + !actionErrorMessage + ) { + void checkAppUpdateQuery() + } + return actionResult }, [ diff --git a/src/app/components/common/icon-button.tsx b/src/app/components/common/icon-button.tsx index 5a368733..76f5a5a7 100644 --- a/src/app/components/common/icon-button.tsx +++ b/src/app/components/common/icon-button.tsx @@ -1,4 +1,4 @@ -import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from 'react' +import type { ButtonHTMLAttributes, ReactNode, Ref } from 'react' import { iconButtonClass } from '../../ui/classes' import { cn } from '../../utils/cn' import { Tooltip } from './tooltip' @@ -9,50 +9,47 @@ type IconButtonProps = Omit, 'children'> active?: boolean tooltip?: string | null tooltipPlacement?: 'top' | 'right' | 'left' + ref?: Ref | undefined } -export const IconButton = forwardRef( - function IconButtonComponent( - { - label, - icon, - tooltip, - tooltipPlacement, - onClick, - active, - className, - type = 'button', - ...buttonProps - }, - ref, - ) { - const button = ( - - ) +export function IconButton({ + label, + icon, + tooltip, + tooltipPlacement, + onClick, + active, + className, + type = 'button', + ref, + ...buttonProps +}: IconButtonProps) { + const button = ( + + ) - if (tooltip === null) { - return button - } + if (tooltip === null) { + return button + } - return ( - - {button} - - ) - }, -) + return ( + + {button} + + ) +} diff --git a/src/app/components/common/primary-button.tsx b/src/app/components/common/primary-button.tsx index 6d607939..31e489bb 100644 --- a/src/app/components/common/primary-button.tsx +++ b/src/app/components/common/primary-button.tsx @@ -1,29 +1,33 @@ -import { type ButtonHTMLAttributes, forwardRef, type PropsWithChildren } from 'react' +import type { ButtonHTMLAttributes, PropsWithChildren, Ref } from 'react' import { primaryButtonClass } from '../../ui/classes' import { cn } from '../../utils/cn' type PrimaryButtonProps = PropsWithChildren< Omit, 'children'> & { className?: string + ref?: Ref | undefined } > -export const PrimaryButton = forwardRef( - function PrimaryButtonComponent( - { onClick, className, children, type = 'button', title, ...buttonProps }, - ref, - ) { - return ( - - ) - }, -) +export function PrimaryButton({ + onClick, + className, + children, + type = 'button', + title, + ref, + ...buttonProps +}: PrimaryButtonProps) { + return ( + + ) +} diff --git a/src/app/components/common/surface-panel.tsx b/src/app/components/common/surface-panel.tsx index ef27eb0d..6a9a53ea 100644 --- a/src/app/components/common/surface-panel.tsx +++ b/src/app/components/common/surface-panel.tsx @@ -1,19 +1,18 @@ -import { forwardRef, type HTMLAttributes, type PropsWithChildren } from 'react' +import type { HTMLAttributes, PropsWithChildren, Ref } from 'react' import { panelChromeClass } from '../../ui/classes' import { cn } from '../../utils/cn' type SurfacePanelProps = PropsWithChildren< HTMLAttributes & { className?: string + ref?: Ref | undefined } > -export const SurfacePanel = forwardRef( - function SurfacePanelComponent({ className, children, ...props }, ref) { - return ( -
- {children} -
- ) - }, -) +export function SurfacePanel({ className, children, ref, ...props }: SurfacePanelProps) { + return ( +
+ {children} +
+ ) +} diff --git a/src/app/components/common/text-button.tsx b/src/app/components/common/text-button.tsx index 041bc79e..c5fdfd22 100644 --- a/src/app/components/common/text-button.tsx +++ b/src/app/components/common/text-button.tsx @@ -1,29 +1,33 @@ -import { type ButtonHTMLAttributes, forwardRef, type PropsWithChildren } from 'react' +import type { ButtonHTMLAttributes, PropsWithChildren, Ref } from 'react' import { ghostButtonClass } from '../../ui/classes' import { cn } from '../../utils/cn' type TextButtonProps = PropsWithChildren< Omit, 'children'> & { className?: string + ref?: Ref | undefined } > -export const TextButton = forwardRef( - function TextButtonComponent( - { onClick, className, children, type = 'button', title, ...buttonProps }, - ref, - ) { - return ( - - ) - }, -) +export function TextButton({ + onClick, + className, + children, + type = 'button', + title, + ref, + ...buttonProps +}: TextButtonProps) { + return ( + + ) +} diff --git a/src/app/components/common/toolbar-button.tsx b/src/app/components/common/toolbar-button.tsx index c5c76c55..622f108b 100644 --- a/src/app/components/common/toolbar-button.tsx +++ b/src/app/components/common/toolbar-button.tsx @@ -1,4 +1,4 @@ -import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from 'react' +import type { ButtonHTMLAttributes, ReactNode, Ref } from 'react' import { toolbarButtonClass } from '../../ui/classes' import { cn } from '../../utils/cn' @@ -7,26 +7,32 @@ type ToolbarButtonProps = Omit, 'childre icon: ReactNode tooltip?: string trailing?: boolean + ref?: Ref | undefined } -export const ToolbarButton = forwardRef( - function ToolbarButtonComponent( - { label, icon, tooltip, onClick, trailing, className, type = 'button', ...buttonProps }, - ref, - ) { - return ( - - ) - }, -) +export function ToolbarButton({ + label, + icon, + tooltip, + onClick, + trailing, + className, + type = 'button', + ref, + ...buttonProps +}: ToolbarButtonProps) { + return ( + + ) +} diff --git a/src/app/components/settings/settings-panel.tsx b/src/app/components/settings/settings-panel.tsx index 83f80196..30ca416b 100644 --- a/src/app/components/settings/settings-panel.tsx +++ b/src/app/components/settings/settings-panel.tsx @@ -1,5 +1,5 @@ import { Check, GitCommitHorizontal, X } from 'lucide-react' -import { useEffect, useId, useRef } from 'react' +import { useEffect, useEffectEvent, useId, useRef } from 'react' import type { AppSettings, ComposerModel, DesktopActionInvoker } from '../../desktop/types' import { modalPanelClass, panelChromeClass } from '../../ui/classes' import { cn } from '../../utils/cn' @@ -26,6 +26,7 @@ export function SettingsPanel({ const closeButtonRef = useRef(null) const lastFocusedElementRef = useRef(null) const selectedModel = appSettings.gitCommitMessageModel + const closeOnEscape = useEffectEvent(() => onClose()) useEffect(() => { if (!open) { @@ -38,7 +39,7 @@ export function SettingsPanel({ const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault() - onClose() + closeOnEscape() } } @@ -48,7 +49,7 @@ export function SettingsPanel({ window.removeEventListener('keydown', handleKeyDown) lastFocusedElementRef.current?.focus() } - }, [onClose, open]) + }, [open]) if (!open) { return null diff --git a/src/app/components/sidebar/inbox/sidebar-inbox-section.tsx b/src/app/components/sidebar/inbox/sidebar-inbox-section.tsx index e21c425b..5d8eb467 100644 --- a/src/app/components/sidebar/inbox/sidebar-inbox-section.tsx +++ b/src/app/components/sidebar/inbox/sidebar-inbox-section.tsx @@ -1,27 +1,37 @@ -import { Clock3, ListFilter, Search, SquareTerminal } from 'lucide-react' -import { useMemo, useState } from 'react' -import type { InboxThread } from '../../../desktop/types' +import { + BrushCleaning, + Check, + Clock3, + Inbox, + ListFilter, + Mail, + Search, + SquareTerminal, + X, +} from 'lucide-react' +import { type ReactNode, useCallback, useMemo, useRef, useState } from 'react' +import type { DesktopActionInvoker, InboxThread } from '../../../desktop/types' +import { useDismissibleLayer } from '../../../hooks/useDismissibleLayer' import { EmptyStateCard } from '../../common/empty-state-card' import { IconButton } from '../../common/icon-button' +import { SurfacePanel } from '../../common/surface-panel' import { InboxThreadRow } from './inbox-thread-row' +type InboxFilterMode = 'all' | 'terminal' | 'recent' + type SidebarInboxSectionProps = { appLaunchedAtMs: number terminalRunningSessionPaths: ReadonlySet threads: InboxThread[] selectedSessionPath: string | null + onAction: DesktopActionInvoker onDismissThread: (thread: InboxThread) => void onSelectThread: (thread: InboxThread) => void } -function getNextInboxFilterMode(current: 'all' | 'terminal' | 'recent') { - if (current === 'all') return 'terminal' - return current === 'terminal' ? 'recent' : 'all' -} - function matchesInboxFilter( thread: InboxThread, - filterMode: 'all' | 'terminal' | 'recent', + filterMode: InboxFilterMode, terminalRunningSessionPaths: ReadonlySet, appLaunchedAtMs: number, ) { @@ -37,17 +47,17 @@ function matchesInboxSearch(thread: InboxThread, normalizedQuery: string) { .includes(normalizedQuery) } -function getInboxFilterIcon(filterMode: 'all' | 'terminal' | 'recent') { +function getInboxFilterIcon(filterMode: InboxFilterMode) { if (filterMode === 'terminal') return return filterMode === 'recent' ? : } -function getInboxFilterLabel(filterMode: 'all' | 'terminal' | 'recent') { +function getInboxFilterLabel(filterMode: InboxFilterMode) { if (filterMode === 'terminal') return 'Show inbox threads with terminals' return filterMode === 'recent' ? 'Show inbox threads active since launch' : 'Filter inbox threads' } -function getEmptyInboxMessage(showUnreadOnly: boolean, filterMode: 'all' | 'terminal' | 'recent') { +function getEmptyInboxMessage(showUnreadOnly: boolean, filterMode: InboxFilterMode) { if (showUnreadOnly) return 'No unread threads right now.' if (filterMode === 'terminal') return 'No inbox threads have a running terminal.' return filterMode === 'recent' @@ -55,19 +65,135 @@ function getEmptyInboxMessage(showUnreadOnly: boolean, filterMode: 'all' | 'term : 'Nothing to catch up on yet.' } +const inboxFilterItems: Array<{ id: InboxFilterMode; label: string; icon: ReactNode }> = [ + { id: 'all', label: 'All', icon: }, + { id: 'terminal', label: 'Terminals', icon: }, + { id: 'recent', label: 'Since launch', icon: }, +] + +const inboxClearItems: Array<{ label: string; olderThanDays: number | null }> = [ + { label: 'Older than 1 day', olderThanDays: 1 }, + { label: 'Older than 7 days', olderThanDays: 7 }, + { label: 'Older than 30 days', olderThanDays: 30 }, + { label: 'All read items', olderThanDays: null }, +] + +function SidebarInboxFilterMenu({ + menuId, + filterMode, + panelRef, + onSelect, +}: { + menuId: string + filterMode: InboxFilterMode + panelRef: React.RefObject + onSelect: (filterMode: InboxFilterMode) => void +}) { + return ( + + {inboxFilterItems.map((item) => { + const selected = item.id === filterMode + + return ( + + ) + })} + + ) +} + +function SidebarInboxClearMenu({ + menuId, + panelRef, + onSelect, +}: { + menuId: string + panelRef: React.RefObject + onSelect: (olderThanDays: number | null) => void +}) { + return ( + + {inboxClearItems.map((item) => ( + + ))} + + ) +} + export function SidebarInboxSection({ appLaunchedAtMs, terminalRunningSessionPaths, threads, selectedSessionPath, + onAction, onDismissThread, onSelectThread, }: SidebarInboxSectionProps) { const [searchQuery, setSearchQuery] = useState('') const [showUnreadOnly, setShowUnreadOnly] = useState(false) - const [filterMode, setFilterMode] = useState<'all' | 'terminal' | 'recent'>('all') + const [clearOpen, setClearOpen] = useState(false) + const [filterMode, setFilterMode] = useState('all') + const [filterOpen, setFilterOpen] = useState(false) + const clearButtonRef = useRef(null) + const clearPanelRef = useRef(null) + const filterButtonRef = useRef(null) + const filterPanelRef = useRef(null) + + const dismissClear = useCallback(() => { + setClearOpen(false) + }, []) + + const dismissFilter = useCallback(() => { + setFilterOpen(false) + }, []) - const cycleFilterMode = () => setFilterMode(getNextInboxFilterMode) + useDismissibleLayer({ + open: clearOpen, + onDismiss: dismissClear, + refs: [clearButtonRef, clearPanelRef], + }) + + useDismissibleLayer({ + open: filterOpen, + onDismiss: dismissFilter, + refs: [filterButtonRef, filterPanelRef], + }) const visibleThreads = useMemo(() => { const normalizedQuery = searchQuery.trim().toLowerCase() @@ -92,7 +218,7 @@ export function SidebarInboxSection({ return (
- + {searchQuery.length > 0 ? ( + + ) : null} +
} + onClick={() => { + setFilterOpen(false) + setClearOpen((open) => !open) + }} + aria-haspopup="menu" + aria-expanded={clearOpen} + aria-controls="sidebar-inbox-clear-menu" + /> + { + setClearOpen(false) + setFilterOpen((open) => !open) + }} + aria-haspopup="menu" + aria-expanded={filterOpen} + aria-controls="sidebar-inbox-filter-menu" /> } + icon={} active={showUnreadOnly} onClick={() => setShowUnreadOnly((current) => !current)} />
+ + {clearOpen ? ( + { + setClearOpen(false) + void onAction('inbox.clear-read', { olderThanDays }) + }} + /> + ) : null} + + {filterOpen ? ( + { + setFilterMode(nextFilterMode) + setFilterOpen(false) + }} + /> + ) : null} {visibleThreads.length > 0 ? ( diff --git a/src/app/components/sidebar/project-tree.tsx b/src/app/components/sidebar/project-tree.tsx index 3f1e8996..dd4c0c67 100644 --- a/src/app/components/sidebar/project-tree.tsx +++ b/src/app/components/sidebar/project-tree.tsx @@ -217,6 +217,7 @@ export function ProjectTree({ isDragging={isDragging} isEditing={editingProjectId === project.id} isExpanded={effectiveIsExpanded} + pinned={Boolean(project.pinned)} hasRepoOrigin={Boolean(project.repoOriginUrl)} canEdit={!selectionModeActive} canToggleExpanded={!selectionModeActive} @@ -246,6 +247,10 @@ export function ProjectTree({ onProjectPrimeSelection(project.id) } else { onProjectSelect(project.id) + void onAction('thread.new', { + projectId: project.id, + composerMode: 'code', + }) } setOpenProjectMenuId(null) }} diff --git a/src/app/components/sidebar/project-tree/project-row.tsx b/src/app/components/sidebar/project-tree/project-row.tsx index d97a9d7d..10911069 100644 --- a/src/app/components/sidebar/project-tree/project-row.tsx +++ b/src/app/components/sidebar/project-tree/project-row.tsx @@ -1,5 +1,5 @@ import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core' -import { ChevronDown, ChevronRight, Folder, Github, MoreHorizontal, Plus } from 'lucide-react' +import { ChevronDown, ChevronRight, Folder, Github, MoreHorizontal, Plus, Star } from 'lucide-react' import { useEffect, useRef } from 'react' import { compactIconButtonClass } from '../../../ui/classes' import { cn } from '../../../utils/cn' @@ -19,6 +19,7 @@ type ProjectRowProps = { isActive: boolean isDragging: boolean isExpanded: boolean + pinned: boolean hasRepoOrigin: boolean name: string renameDraft: string @@ -53,6 +54,7 @@ function ProjectRowName({ onCancelEdit, onChangeRenameDraft, onSubmitEdit, + pinned, renameDraft, }: Pick< ProjectRowProps, @@ -60,6 +62,7 @@ function ProjectRowName({ | 'isActive' | 'isEditing' | 'name' + | 'pinned' | 'onCancelEdit' | 'onChangeRenameDraft' | 'onSubmitEdit' @@ -110,6 +113,9 @@ function ProjectRowName({ aria-current={isActive ? 'page' : undefined} > {name} + {pinned ? ( + + ) : null} ) } @@ -181,6 +187,7 @@ export function ProjectRow({ isActive, isDragging, isExpanded, + pinned, hasRepoOrigin, name, renameDraft, @@ -273,6 +280,7 @@ export function ProjectRow({ isActive={isActive} isEditing={isEditing} name={name} + pinned={pinned} onCancelEdit={onCancelEdit} onChangeRenameDraft={onChangeRenameDraft} onSubmitEdit={onSubmitEdit} diff --git a/src/app/components/sidebar/projects/sidebar-projects-create-popover.tsx b/src/app/components/sidebar/projects/sidebar-projects-create-popover.tsx index d0b4fccd..e186657d 100644 --- a/src/app/components/sidebar/projects/sidebar-projects-create-popover.tsx +++ b/src/app/components/sidebar/projects/sidebar-projects-create-popover.tsx @@ -34,9 +34,7 @@ export function SidebarProjectsCreatePopover({ const [browseSearchQuery, setBrowseSearchQuery] = useState('') const [currentFolderPath, setCurrentFolderPath] = useState(null) const canSubmit = - draft.trim().length > 0 && - !busy && - (Boolean(defaultLocation) || (browseOpen && Boolean(currentFolderPath))) + draft.trim().length > 0 && !busy && Boolean(browseOpen ? currentFolderPath : defaultLocation) useEffect(() => { if (!open) { @@ -85,7 +83,10 @@ export function SidebarProjectsCreatePopover({ + ) : null} + {showProjects ? (
{ + setCreateOpen(false) + setFilterOpen((open) => !open) + }} icon={getSidebarProjectFilterIcon(filterMode)} active={filterMode !== 'all'} + aria-haspopup="menu" + aria-expanded={filterOpen} + aria-controls="sidebar-project-filter-menu" /> {showProjectCreate ? ( { setCreateErrorMessage(null) + setFilterOpen(false) setCreateOpen(true) }} icon={} @@ -468,6 +494,19 @@ export function SidebarProjectsSection({
) : null} + {filterOpen ? ( + { + setFilterMode(nextFilterMode) + setFilterOpen(false) + }} + /> + ) : null} + {createOpen ? ( void + onOpenAbout: () => void onOpenSkillsView: () => void onOpenSettingsPanel: (target?: SettingsOpenTarget) => void onOpenArchivedThreads: () => void @@ -21,13 +22,14 @@ export function SettingsMenu({ menuId, open, onOpenExtensionsView, + onOpenAbout, onOpenSkillsView, onOpenSettingsPanel, onOpenArchivedThreads, panelRef, }: SettingsMenuProps) { const { step, isRunning, advance } = useAppUpdateFlow() - const updateDisabled = isRunning || step.id === 'up-to-date' + const updateDisabled = isRunning const UpdateIcon = step.id === 'idle' || step.id === 'up-to-date' || @@ -45,6 +47,7 @@ export function SettingsMenu({ statusId?: FeatureStatusId disabled?: boolean }> = [ + { icon: , title: 'About', onClick: onOpenAbout }, { icon: , title: 'Skills', onClick: onOpenSkillsView }, { icon: , title: 'Extensions', onClick: onOpenExtensionsView }, { icon: , title: 'Archived threads', onClick: onOpenArchivedThreads }, diff --git a/src/app/components/sidebar/sidebar.tsx b/src/app/components/sidebar/sidebar.tsx index 728cacf1..14073f39 100644 --- a/src/app/components/sidebar/sidebar.tsx +++ b/src/app/components/sidebar/sidebar.tsx @@ -53,6 +53,7 @@ type SidebarProps = { onShowView: (view: SidebarNavigableView) => void onToggleSettings: () => void onOpenExtensionsView: () => void + onOpenAbout: () => void onOpenSkillsView: () => void onOpenSettingsPanel: (target?: SettingsOpenTarget) => void onOpenArchivedThreads: () => void @@ -143,6 +144,7 @@ function SidebarContent(props: SidebarProps) { terminalRunningSessionPaths={props.terminalRunningSessionPaths} threads={props.inboxThreads} selectedSessionPath={props.selectedInboxSessionPath} + onAction={props.onAction} onDismissThread={props.onDismissInboxThread} onSelectThread={props.onSelectInboxThread} /> @@ -215,6 +217,7 @@ export function Sidebar({ onShowView, onToggleSettings, onOpenExtensionsView, + onOpenAbout, onOpenSkillsView, onOpenSettingsPanel, onOpenArchivedThreads, @@ -275,6 +278,7 @@ export function Sidebar({ onShowView, onToggleSettings, onOpenExtensionsView, + onOpenAbout, onOpenSkillsView, onOpenSettingsPanel, onOpenArchivedThreads, @@ -346,6 +350,7 @@ export function Sidebar({ open={settingsOpen} panelRef={settingsMenuRef} onOpenExtensionsView={onOpenExtensionsView} + onOpenAbout={onOpenAbout} onOpenSkillsView={onOpenSkillsView} onOpenSettingsPanel={onOpenSettingsPanel} onOpenArchivedThreads={onOpenArchivedThreads} diff --git a/src/app/components/workspace/composer/composer-context-meter.tsx b/src/app/components/workspace/composer/composer-context-meter.tsx index da885ac7..efca5c5b 100644 --- a/src/app/components/workspace/composer/composer-context-meter.tsx +++ b/src/app/components/workspace/composer/composer-context-meter.tsx @@ -224,6 +224,11 @@ export function ComposerContextMeter({ setHovered(false) }, [clearHoverTriangle]) + const togglePinned = useCallback(() => { + loadUsageTotals() + setPinned((current) => !current) + }, [loadUsageTotals]) + const handleMouseLeave = useCallback( (event: MouseEvent) => { if (pinned) { @@ -251,10 +256,17 @@ export function ComposerContextMeter({ ) useEffect(() => clearHoverTriangle, [clearHoverTriangle]) + + useEffect(() => { + if (open) { + loadUsageTotals() + } + }, [loadUsageTotals, open]) + return (
@@ -262,7 +274,7 @@ export function ComposerContextMeter({ ref={buttonRef} type="button" className="relative inline-flex h-7 w-7 items-center justify-center rounded-full text-[color:var(--muted)] transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--text)]" - onClick={() => setPinned((current) => !current)} + onClick={togglePinned} onPointerDown={loadUsageTotals} aria-label={label} aria-expanded={open} diff --git a/src/app/components/workspace/composer/composer-diff-baseline-selector.tsx b/src/app/components/workspace/composer/composer-diff-baseline-selector.tsx index a1221240..4b0a7a9d 100644 --- a/src/app/components/workspace/composer/composer-diff-baseline-selector.tsx +++ b/src/app/components/workspace/composer/composer-diff-baseline-selector.tsx @@ -44,6 +44,8 @@ const baselineOptions = [ > }> +const BASELINE_POPOVER_WIDTH = 400 + function matchesCommitSearch(commit: ProjectCommitEntry, query: string) { const normalizedQuery = query.trim().toLowerCase() if (normalizedQuery.length === 0) { @@ -103,7 +105,7 @@ function BaselineOption({ ) } @@ -148,6 +150,29 @@ function getActiveBaselineAnchorRef(input: { return input.anchorRef } +function getVisibleAnchorRect(anchorRef: RefObject) { + const element = anchorRef.current + if (!element) return null + const rect = element.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) return null + return rect +} + +function getResponsiveAnchorRect(input: { + activeAnchor: 'summary' | 'branch' | 'compact' + anchorRef: RefObject + branchAnchorRef: RefObject + compactAnchorRef: RefObject +}) { + const activeAnchorRect = getVisibleAnchorRect(getActiveBaselineAnchorRef(input)) + if (activeAnchorRect) return activeAnchorRect + return ( + getVisibleAnchorRect(input.compactAnchorRef) ?? + getVisibleAnchorRect(input.branchAnchorRef) ?? + getVisibleAnchorRect(input.anchorRef) + ) +} + function BaselineSummaryButton({ baselineLabel, baselinePrefix, @@ -270,7 +295,7 @@ function BaselineSelectorPortal({ commitsQuery: ReturnType> onSelectBaseline: (baseline: ProjectDiffBaseline) => void panelId: string - panelPosition: { right: number; bottom: number } + panelPosition: { left: number; bottom: number; width: number; maxHeight: number } panelRef: RefObject positionReady: boolean searchQuery: string @@ -281,6 +306,8 @@ function BaselineSelectorPortal({ visibleCommits: ProjectCommitEntry[] }) { if (typeof document === 'undefined') return null + const panelLeft = `${panelPosition.left}px` + const panelWidth = `${panelPosition.width}px` return createPortal(
Changes since
-
+
{visibleCommits.length > 0 ? ( visibleCommits.map((commit) => ( ('summary') const panelId = useId() const anchorRef = useRef(null) const branchAnchorRef = useRef(null) const compactAnchorRef = useRef(null) const panelRef = useRef(null) - const [panelPosition, setPanelPosition] = useState({ right: 20, bottom: 20 }) + const activeAnchorRef = useRef<'summary' | 'branch' | 'compact'>('summary') + const [panelPosition, setPanelPosition] = useState({ + left: 16, + bottom: 20, + maxHeight: 360, + width: BASELINE_POPOVER_WIDTH, + }) const commitsQuery = useQuery({ queryKey: desktopQueryKeys.projectCommits(projectId, 100), @@ -393,7 +430,7 @@ export function ComposerDiffBaselineSelector({ ? commits.filter((commit) => matchesCommitSearch(commit, searchQuery)) : commits - return nextCommits.slice(0, 10) + return nextCommits.slice(0, 5) }, [commits, searchQuery]) const counts = useMemo( @@ -406,9 +443,15 @@ export function ComposerDiffBaselineSelector({ [baselineStatsQuery.data, projectGitState, selectedBaseline], ) + const closePopover = () => setOpen(false) + const togglePopover = (anchor: 'summary' | 'branch' | 'compact') => { + activeAnchorRef.current = anchor + setOpen((current) => !current) + } + useDismissibleLayer({ open, - onDismiss: () => setOpen(false), + onDismiss: closePopover, refs: [anchorRef, branchAnchorRef, compactAnchorRef, panelRef], }) @@ -426,21 +469,26 @@ export function ComposerDiffBaselineSelector({ const updatePosition = () => { const composerRect = composerPanelRef.current?.getBoundingClientRect() - const activeAnchorRef = getActiveBaselineAnchorRef({ - activeAnchor, + const anchorRect = getResponsiveAnchorRect({ + activeAnchor: activeAnchorRef.current, anchorRef, branchAnchorRef, compactAnchorRef, }) - const anchorRect = activeAnchorRef.current?.getBoundingClientRect() if (!(composerRect && anchorRect)) { return } - setPanelPosition({ - right: Math.max(window.innerWidth - composerRect.right, 20), - bottom: Math.max(window.innerHeight - anchorRect.top + 8, 20), - }) + const viewportGutter = 8 + const width = Math.min(BASELINE_POPOVER_WIDTH, composerRect.width) + const minLeft = viewportGutter + const maxLeft = Math.max(minLeft, window.innerWidth - width - viewportGutter) + const preferredLeft = composerRect.right - width + const left = Math.min(Math.max(preferredLeft, minLeft), maxLeft) + const bottom = Math.max(window.innerHeight - anchorRect.top + 8, viewportGutter) + const maxHeight = Math.max(160, window.innerHeight - bottom - viewportGutter) + + setPanelPosition({ left, bottom, maxHeight, width }) setPositionReady(true) } @@ -452,7 +500,7 @@ export function ComposerDiffBaselineSelector({ window.removeEventListener('resize', updatePosition) window.removeEventListener('scroll', updatePosition, true) } - }, [activeAnchor, composerPanelRef, open]) + }, [composerPanelRef, open]) const fileCountLabel = counts ? formatGitCount(counts.fileCount) : '…' const insertionCountLabel = counts ? formatGitCount(counts.insertions) : '…' @@ -471,10 +519,7 @@ export function ComposerDiffBaselineSelector({ fileCountLabel={fileCountLabel} insertionCountLabel={insertionCountLabel} open={open} - onOpen={() => { - setActiveAnchor('summary') - setOpen((current) => !current) - }} + onOpen={() => togglePopover('summary')} /> {showBranchChip ? ( { - setActiveAnchor('branch') - setOpen((current) => !current) - }} + onOpen={() => togglePopover('branch')} /> ) : null} @@ -138,28 +179,35 @@ export function ComposerGitOpsFooter({
{hasOrigin ? null : ( - onSetRepoUrl(event.target.value)} - onBlur={() => { - saveOriginOnce() - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() + )} ) : null}
+ {hasOrigin ? null : ( + + )}