diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..8dffb63f38 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,77 @@ +name: Build + +# Unsigned test builds for every desktop platform, downloadable from the run's +# artifacts. Release signing, notarization and the updater feed stay in +# release.yml, so this needs no secrets. +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/build.yml + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: macOS (Apple Silicon) + os: macos-latest + artifact: monocode-macos-arm64 + bundles: app,dmg + paths: | + target/release/bundle/dmg/*.dmg + - name: Linux + os: ubuntu-22.04 + artifact: monocode-linux-x64 + bundles: deb,appimage + paths: | + target/release/bundle/deb/*.deb + target/release/bundle/appimage/*.AppImage + - name: Windows + os: windows-latest + artifact: monocode-windows-x64 + bundles: nsis + paths: | + target/release/bundle/nsis/*.exe + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - uses: dtolnay/rust-toolchain@stable + + - name: Install Linux system dependencies + if: runner.os == 'Linux' + run: bash scripts/install-linux-deps-debian.sh + + - run: npm ci + + # Updater artifacts need the release signing key; test builds skip them. + - name: Build + shell: bash + run: | + set -euo pipefail + echo '{"bundle":{"createUpdaterArtifacts":false}}' > "$RUNNER_TEMP/tauri.build.conf.json" + npx tauri build --bundles "${{ matrix.bundles }}" --config "$RUNNER_TEMP/tauri.build.conf.json" + if [[ "$RUNNER_OS" == "Linux" ]]; then bash scripts/repack-appimage.sh; fi + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ${{ matrix.paths }} + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 936f329201..acb93e9ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The OpenCode model picker accepts a **custom model**: type a `provider/model` id the catalog doesn't list (for example `openrouter/qwen/qwen3-coder`) and pick **Use provider/model** under Custom model. The choice is kept as typed instead of falling back to another model, and can be favorited. + ### Fixed - OpenCode sessions in **Auto** no longer ask before every tool call. Auto now proxies OpenCode's own auto-accept (the same as `opencode --auto` and the TUI's auto accept): Monocode stops overriding OpenCode's permission rules and accepts every request they still ask about, so only what your OpenCode config denies is blocked. The access picker says so for OpenCode sessions. Supervised and Auto-accept edits stop asking before look-only tools such as grep, glob, list, and task-list updates. +## [0.13.0] - 2026-10-12 + +### Added + +- Monos and habit runs can create **HTML visual artifacts** with `app artifacts.write` using `kind: "html"`, alongside Markdown documents. Visuals run in a sandboxed frame, follow the app's theme, fit their content height, and refresh when revised. Scripts and public HTTPS assets are supported; access to the app, network requests through fetch, nested frames, and form submissions are blocked. Clicked web links open in the user's browser. +- Mono replies display linked **local images and videos inline**, including workspace-relative media references. Images open in the file viewer when clicked, and videos have playback controls. Previews load as they approach the visible chat, reserve their space to avoid layout shifts, and fall back to the original link when a linked file cannot be displayed. Local videos stream without reading the whole file into memory; only the requested video file is added to the asset scope. +- The Mono composer has an **emoji picker** with search, categories, recent picks, and a saved skin-tone preference. Picks replace the selected text or insert at the caret and return focus to the composer. Its bundled catalog works offline across platforms and filters out emoji versions and country flags the system's font cannot draw. +- **Model and effort controls** sit beneath the Mono composer, alongside its context usage meter. Compact plan-usage chips show Claude Code, Codex, OpenCode, and Devin usage beside the attachment and emoji buttons when available. +- Sessions a Mono starts appear as **status cards beneath its replies**, showing their title, provider, project, and whether they are working, need input, are drafts, failed, stopped, or finished. Cards open the session inside the overview and remain visible when a later completion report continues the turn. +- The Mono overview's Sessions tab has **search and filters** for provider, status, time, archived sessions, and unavailable sessions. Search also matches project names. Sessions appear newest first and load another page as the list nears its end; changing the search or filters resets paging. Empty tabs and searches show guidance for the current state. +- MonoCode **reopens the last open Mono on launch** when it still exists and Monos are enabled. Closing the Mono clears that choice, and windows transferred from another window retain their own view. + +### Changed + +- A unified **Mono overview** replaces the separate details, sessions, artifacts, and changes sidebars, with Sessions, Documents, Habits, and Changes tabs. It opens beside the chat by default and can be toggled from the title bar. The default width is 400 pixels, and resizing allows up to 960 pixels or 62% of the window. Tabs show item counts; the folded Changes icon has an accent dot when changes are present. +- Mono settings open in a **dialog** from the title bar, with General, Soul, and Memory sections. General brings together model settings, permissions, projects, sidebar preferences, and conversation reset. Clicking the mascot opens the mascot and color popover, and the name field sits directly beneath it. +- Mono-started sessions launch without creating workspace tabs by default and open over the overview's session list, with **Back**, **Open in workspace**, and **Hide overview** controls. New launches stay out of the project sidebar unless **Show sessions in sidebar** is enabled in that Mono's settings; optional folder grouping remains available. +- The overview's **Changes** tab groups repositories into project tabs with their logo or mascot and changed-file count. Worktrees share their project's tab and have a checkout picker. Attached Git projects appear even before they have changes, and repositories remain available after committing so their work can still be pushed. Each checkout retains its commit draft when switching projects or overview tabs. +- Mono commits now use the repository's **real Git staging area**, shared with the project's Changes panel. The view lists staged and unstaged files, supports staging or unstaging files, folders, or all changes, and offers discard, list/tree views, commit, push, sync, and pull request actions. Generated messages and commits use all staged changes in the active checkout. Fully committed Mono files leave its session review; partially staged files retain their remaining review. +- Clicking a changed file opens its **inline diff editor** over the Changes list, with Back navigation. Changed images and videos open in the media preview instead. +- Documents and visuals open inside the overview's **Documents** tab. Reply cards use compact title chips with document or chart icons, and the reader keeps its title in the navigation header without repeating the title and update timestamp above the content. Copy and delete controls remain available. +- Mono session cards show their project and place the model's provider mark beside the branch in the footer, revealing the model name on hover or focus so titles have more room. +- Habits in the overview are grouped into **Up next today**, **Done today**, and **Later**, with group counts and paused habits last. When nothing is scheduled today, the remaining group is labeled Scheduled. +- Streamed replies show text **as it arrives**, with word fades adapting to the stream's cadence between 120 and 400 milliseconds. The earlier paced reveal remains available through `localStorage["monocode.pacedReveal"] = "1"`. Transcript following glides with incoming content, bounds the speed of Mono reply reveals, and respects entrance-animation settings. +- Mono turn actions appear on hover or keyboard focus in a reserved gap and stay visible while their panel is open. Reaction badges use translucent chips at the message corner, and the next user message stacks with a message answered only by a reaction. Handoff and second-opinion controls are hidden in the Mono view. +- New conversations show an **empty context ring** before usage is reported. A zero-usage ring no longer draws a progress dot. +- Regression coverage now includes Mono overview navigation, settings and popovers, memory editing, session search and paging, habit grouping, multi-repository staging and commits, HTML artifact persistence, local media references, session-state synchronization, Cursor catalog fallbacks, emoji data, and reply pacing. Chromium and WebKit tests cover the emoji picker, tab drags, Mono completion layout, and interrupted scroll glides; Linux drag fixtures mock native window controls, and wheel-interruption checks wait for scrolling to settle. + +### Fixed + +- Cursor model discovery uses existing CLI credentials without starting the **browser login flow**. Missing credentials or ACP failures fall back to CLI discovery and then the bundled model catalog. +- Background agent updates preserve pending **account changes, session titles, newly opened sessions, and streamed output** when several updates arrive before React renders, including under Strict Mode. +- Switching Monos clears the previous Mono's open session, document, focus, search, and filters so its overview does not leak into another conversation. +- Saved Mono session summaries and habits are cached so returning to their lists avoids a blank reload. Session lookup also finds saved sessions moved to another project; loading cards reserve their space, and unavailable sessions can be inspected through the filter. +- Reused sessions are recovered from successful **follow-up receipts** as well as launch receipts, using saved metadata or session listings. Merely listing or reading a session does not create a chat card. +- Completion reports and session cards attach without shifting Mono transcript spacing or interrupting reply fades. Ready completion prompts stay hidden while being delivered; waiting or failed prompts remain visible, including failures that need a retry. +- Manual scrolling interrupts Mono reply glides, ambiguous wheel gestures pause following, and scrolling a nested code block keeps its own scroll behavior. Following offsets no longer enlarge the transcript's scroll range, and focusing a session during the overview's slide animation no longer scrolls the surrounding panel out of place. +- Session and artifact cards fit narrow panes, truncating long titles and project labels instead of overflowing their container. Popovers opened from dialogs stay above them, and Escape closes the popover before the dialog. +- Video previews defer releasing their source until the element is actually gone, preventing Linux WebKit from stalling without metadata when React replays effects during an in-progress load. +- Opening a particular file in a multi-file diff expands that file even when the other files start folded. +- Sideways tab drags require a deliberate pull below the tab bar before targeting a pane. Fast releases in WebKit keep their reorder even when queued pointer moves report no held buttons, while missed releases still cancel the drag. + ## [0.12.0] - 2026-10-10 ### Added @@ -1438,7 +1484,8 @@ First public release. macOS (Apple Silicon) only. - Updater endpoint and minisign public key are injected at release time rather than committed, so forks do not inherit the maintainer's update channel. - macOS release builds sign with `APPLE_SIGNING_IDENTITY` via a config overlay; the committed default remains ad-hoc `-` for community builds. -[Unreleased]: https://github.com/hardbeat920/monocode/compare/v0.12.0...HEAD +[Unreleased]: https://github.com/hardbeat920/monocode/compare/v0.13.0...HEAD +[0.13.0]: https://github.com/hardbeat920/monocode/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/hardbeat920/monocode/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/hardbeat920/monocode/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/hardbeat920/monocode/compare/v0.9.0...v0.10.0 diff --git a/Cargo.lock b/Cargo.lock index b95939df12..fcc4c53a9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2493,7 +2493,7 @@ dependencies = [ [[package]] name = "monocode" -version = "0.12.0" +version = "0.13.0" dependencies = [ "arboard", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 6a972819ee..670604ffb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["src-tauri"] exclude = ["vendor/portable-pty"] [workspace.package] -version = "0.12.0" +version = "0.13.0" edition = "2021" license = "MIT" diff --git a/package-lock.json b/package-lock.json index c44f0d25fc..1a998c5c3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "monocode-desktop", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "monocode-desktop", - "version": "0.12.0", + "version": "0.13.0", "dependencies": { "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", @@ -14,11 +14,14 @@ "@codemirror/lang-java": "^6.0.2", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-less": "^6.0.2", "@codemirror/lang-markdown": "^6.5.2", "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sass": "^6.0.2", "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-vue": "^0.1.3", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.3", "@codemirror/legacy-modes": "^6.5.4", @@ -28,6 +31,7 @@ "@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/react": "^1.1.10", "@lezer/highlight": "^1.2.3", + "@replit/codemirror-lang-svelte": "^6.0.0", "@streamdown/code": "^1.1.1", "@streamdown/mermaid": "^1.0.2", "@tauri-apps/api": "^2.7.0", @@ -36,9 +40,12 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", "cuelume": "^0.2.2", + "emojibase-data": "^17.0.0", + "frimousse": "^0.4.0", "prettier": "^3.9.6", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -470,6 +477,19 @@ "@lezer/json": "^1.0.0" } }, + "node_modules/@codemirror/lang-less": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", + "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@codemirror/lang-markdown": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz", @@ -521,6 +541,19 @@ "@lezer/rust": "^1.0.0" } }, + "node_modules/@codemirror/lang-sass": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", + "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/sass": "^1.0.0" + } + }, "node_modules/@codemirror/lang-sql": { "version": "6.10.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", @@ -535,6 +568,20 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@codemirror/lang-vue": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", + "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, "node_modules/@codemirror/lang-xml": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", @@ -1300,6 +1347,17 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@lezer/sass": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/xml": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", @@ -1370,6 +1428,25 @@ "node": ">=20" } }, + "node_modules/@replit/codemirror-lang-svelte": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-svelte/-/codemirror-lang-svelte-6.0.0.tgz", + "integrity": "sha512-U2OqqgMM6jKelL0GNWbAmqlu1S078zZNoBqlJBW+retTc5M4Mha6/Y2cf4SVg6ddgloJvmcSpt4hHrVoM4ePRA==", + "license": "MIT", + "peerDependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.1", + "@codemirror/lang-html": "^6.2.0", + "@codemirror/lang-javascript": "^6.1.1", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/javascript": "^1.2.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3007,6 +3084,12 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", @@ -3884,6 +3967,33 @@ "dev": true, "license": "ISC" }, + "node_modules/emojibase": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/emojibase/-/emojibase-17.0.0.tgz", + "integrity": "sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + } + }, + "node_modules/emojibase-data": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-17.0.0.tgz", + "integrity": "sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==", + "license": "MIT", + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + }, + "peerDependencies": { + "emojibase": "*" + } + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -4046,6 +4156,25 @@ } } }, + "node_modules/frimousse": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/frimousse/-/frimousse-0.4.0.tgz", + "integrity": "sha512-wA/1Xt9UTk51wYNpy+EvHKPUA4jdm4NuEFzSNNZxqT0Nxrj4RFwYByjRMuaq4qSLOco/ikDCpLKesB8W1w2q+Q==", + "license": "MIT", + "workspaces": [ + ".", + "site" + ], + "peerDependencies": { + "react": "^18 || ^19", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6472,7 +6601,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 69e1e675dd..53f4ead955 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "monocode-desktop", "private": true, - "version": "0.12.0", + "version": "0.13.0", "type": "module", "scripts": { "dev": "vite", @@ -35,11 +35,14 @@ "@codemirror/lang-java": "^6.0.2", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-less": "^6.0.2", "@codemirror/lang-markdown": "^6.5.2", "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sass": "^6.0.2", "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-vue": "^0.1.3", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.3", "@codemirror/legacy-modes": "^6.5.4", @@ -49,6 +52,7 @@ "@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/react": "^1.1.10", "@lezer/highlight": "^1.2.3", + "@replit/codemirror-lang-svelte": "^6.0.0", "@streamdown/code": "^1.1.1", "@streamdown/mermaid": "^1.0.2", "@tauri-apps/api": "^2.7.0", @@ -57,9 +61,12 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", "cuelume": "^0.2.2", + "emojibase-data": "^17.0.0", + "frimousse": "^0.4.0", "prettier": "^3.9.6", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src-tauri/src/artifacts.rs b/src-tauri/src/artifacts.rs index 74701da9a9..33781b9e31 100644 --- a/src-tauri/src/artifacts.rs +++ b/src-tauri/src/artifacts.rs @@ -3,7 +3,7 @@ use std::borrow::Cow; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use crate::notes::{save_content, Note, NoteUpsert}; use crate::session_store::{validate_id, SessionStore}; @@ -12,12 +12,14 @@ use crate::session_store::{validate_id, SessionStore}; #[serde(rename_all = "lowercase")] pub enum ArtifactKind { Document, + Html, } impl ArtifactKind { fn as_str(self) -> &'static str { match self { Self::Document => "document", + Self::Html => "html", } } } @@ -187,6 +189,99 @@ pub fn artifacts_upsert( .map(|saved| Artifact::from_saved(saved, kind)) } +/// Scheme that serves `html` artifacts to the sandboxed frames that show them. +pub const HTML_SCHEME: &str = "monoartifact"; + +/// The page may run inline scripts and load public styles, scripts, images and +/// fonts, but it cannot fetch, frame, submit forms or leave its sandbox, even +/// if something opens its URL outside a sandboxed frame. +const HTML_CSP: &str = "sandbox allow-scripts; default-src 'none'; \ + script-src 'unsafe-inline' https:; style-src 'unsafe-inline' https:; \ + img-src data: blob: https:; font-src data: https:; media-src data: blob: https:; \ + worker-src blob:; connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'"; + +/// Runs first in every page: applies the theme the app posts, reports the +/// content height so the frame can fit it, and hands web links to the app. +const HTML_BOOTSTRAP: &str = r#""#; + +/// Serves `://localhost/`. Only `html` artifacts are served, so a +/// frame can never show a document's Markdown as a page. +pub fn serve_html( + app: &AppHandle, + request: &tauri::http::Request>, +) -> tauri::http::Response> { + let not_found = || { + tauri::http::Response::builder() + .status(404) + .body(Vec::new()) + .unwrap_or_default() + }; + if request.method() != tauri::http::Method::GET { + return not_found(); + } + let id = request.uri().path().trim_start_matches('/'); + if validate_id(id, "artifact").is_err() { + return not_found(); + } + let Some(store) = app.try_state::() else { + return not_found(); + }; + let artifact = match store.lock_conn() { + Ok(conn) => get_artifact(&conn, id).ok().flatten(), + Err(_) => None, + }; + let Some(artifact) = artifact.filter(|a| a.kind == ArtifactKind::Html) else { + return not_found(); + }; + tauri::http::Response::builder() + .status(200) + .header("Content-Type", "text/html; charset=utf-8") + .header("Content-Security-Policy", HTML_CSP) + .header("Cache-Control", "no-store") + .header("X-Content-Type-Options", "nosniff") + .body(with_bootstrap(&artifact.body).into_bytes()) + .unwrap_or_else(|_| not_found()) +} + +/// Inserts the bootstrap at the start of ``, else after `` or the +/// doctype. Never before the doctype, which would switch to quirks mode. +fn with_bootstrap(body: &str) -> String { + // ASCII lowercasing keeps byte offsets, so they index into `body`. + let lower = body.to_ascii_lowercase(); + let at = tag_end(&lower, "').map(|end| start + end + 1)) + .flatten() + }) + .unwrap_or(0); + let mut page = String::with_capacity(body.len() + HTML_BOOTSTRAP.len()); + page.push_str(&body[..at]); + page.push_str(HTML_BOOTSTRAP); + page.push_str(&body[at..]); + page +} + +/// The byte just past the first opening `name` tag, skipping longer names +/// such as `
` for ` Option { + let mut from = 0; + while let Some(found) = lower[from..].find(name) { + let start = from + found; + let next = start + name.len(); + match lower.as_bytes().get(next) { + Some(b'>' | b'/') | Some(b' ' | b'\t' | b'\n' | b'\r' | b'\x0c') => { + return lower[next..].find('>').map(|end| next + end + 1); + } + _ => from = next, + } + } + None +} + fn list_artifacts(conn: &Connection) -> rusqlite::Result> { let mut stmt = conn.prepare( "SELECT id, artifact_kind, title, body, source_session_id, source_cwd, @@ -213,6 +308,7 @@ fn read_artifact(row: &rusqlite::Row<'_>) -> rusqlite::Result { let kind: String = row.get(1)?; let kind = match kind.as_str() { "document" => ArtifactKind::Document, + "html" => ArtifactKind::Html, _ => { return Err(rusqlite::Error::InvalidParameterName( "Unsupported artifact kind".into(), @@ -236,6 +332,38 @@ mod tests { use super::*; use crate::notes::upsert_content; + #[test] + fn html_artifacts_round_trip_their_kind() { + let store = SessionStore::open_in_memory().unwrap(); + let conn = store.lock_conn().unwrap(); + let note = NoteUpsert { + id: "artifact-html".into(), + title: "Chart".into(), + body: "

hi

".into(), + tags: Vec::new(), + source_session_id: None, + source_cwd: None, + finalize_slug: false, + }; + upsert_content(&conn, ¬e, "artifact", Some("html")).unwrap(); + let artifact = get_artifact(&conn, ¬e.id).unwrap().unwrap(); + assert_eq!(artifact.kind, ArtifactKind::Html); + assert_eq!(serde_json::to_value(artifact).unwrap()["kind"], "html"); + } + + #[test] + fn bootstrap_goes_first_in_head_and_never_before_the_doctype() { + let page = with_bootstrap("x"); + assert!(page.starts_with("
fragment
")); + } + #[test] fn artifact_contract_preserves_kind_and_excludes_note_fields() { let store = SessionStore::open_in_memory().unwrap(); diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs index db000c7247..8a5b48a3e2 100644 --- a/src-tauri/src/control.rs +++ b/src-tauri/src/control.rs @@ -410,7 +410,9 @@ pub fn control_authorize_turn( cwd: String, app_access: bool, ) -> Result<(), String> { - let cwd = std::fs::canonicalize(crate::fs::expand_home(&cwd)).map_err(|e| e.to_string())?; + let workdir = crate::fs::expand_home(&cwd); + let cwd = std::fs::canonicalize(&workdir) + .map_err(|e| format!("Working directory {}: {e}", workdir.display()))?; let cwd = comparison_path(&cwd); let mut inner = host .inner diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index 8b11119eee..62c9419bfa 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -1786,16 +1786,35 @@ fn git_diff_stats_for(root: &Path) -> GitDiffStats { let mut files: HashMap = HashMap::new(); if let Some(text) = git_run( root, - &["diff", "--no-ext-diff", "--numstat", "HEAD", "--", "."], + &[ + "diff", + "-z", + "--no-ext-diff", + "--numstat", + "HEAD", + "--", + ".", + ], ) { add_numstat_map(&text, &mut files); } else { - if let Some(text) = git_run(root, &["diff", "--no-ext-diff", "--numstat", "--", "."]) { + if let Some(text) = git_run( + root, + &["diff", "-z", "--no-ext-diff", "--numstat", "--", "."], + ) { add_numstat_map(&text, &mut files); } if let Some(text) = git_run( root, - &["diff", "--no-ext-diff", "--cached", "--numstat", "--", "."], + &[ + "diff", + "-z", + "--no-ext-diff", + "--cached", + "--numstat", + "--", + ".", + ], ) { add_numstat_map(&text, &mut files); } @@ -1840,6 +1859,7 @@ fn git_diff_index_with(root: &Path, include_sync: bool) -> GitDiffIndex { root, &[ "diff", + "-z", "--relative", "--no-ext-diff", "--numstat", @@ -1853,6 +1873,7 @@ fn git_diff_index_with(root: &Path, include_sync: bool) -> GitDiffIndex { root, &[ "diff", + "-z", "--relative", "--no-ext-diff", "--name-status", @@ -1869,6 +1890,7 @@ fn git_diff_index_with(root: &Path, include_sync: bool) -> GitDiffIndex { root, &[ "diff", + "-z", "--relative", "--no-ext-diff", "--numstat", @@ -1882,6 +1904,7 @@ fn git_diff_index_with(root: &Path, include_sync: bool) -> GitDiffIndex { root, &[ "diff", + "-z", "--relative", "--no-ext-diff", "--cached", @@ -1896,6 +1919,7 @@ fn git_diff_index_with(root: &Path, include_sync: bool) -> GitDiffIndex { root, &[ "diff", + "-z", "--relative", "--no-ext-diff", "--name-status", @@ -1910,6 +1934,7 @@ fn git_diff_index_with(root: &Path, include_sync: bool) -> GitDiffIndex { root, &[ "diff", + "-z", "--relative", "--no-ext-diff", "--cached", @@ -1974,12 +1999,29 @@ fn git_diff_index_with(root: &Path, include_sync: bool) -> GitDiffIndex { } fn add_numstat_map(text: &str, files: &mut HashMap) { - for line in text.lines() { - let mut parts = line.splitn(3, '\t'); + // `git diff -z --numstat` ends each record with NUL. A rename is + // `add del old new `; every other record is + // `add del path `. The path itself may contain spaces, + // quotes, tabs, or non-ASCII bytes, which a newline listing would C-quote. + let mut fields = text.split('\0'); + while let Some(record) = fields.next() { + if record.is_empty() { + continue; + } + let mut parts = record.splitn(3, '\t'); let Some(add) = parts.next() else { continue }; let Some(del) = parts.next() else { continue }; let Some(path) = parts.next() else { continue }; - let relative = normalize_diff_path(path); + let path = if path.is_empty() { + let _previous = fields.next(); + match fields.next() { + Some(destination) if !destination.is_empty() => destination, + _ => continue, + } + } else { + path + }; + let relative = literal_diff_path(path); if relative.is_empty() { continue; } @@ -1992,9 +2034,14 @@ fn add_numstat_map(text: &str, files: &mut HashMap) { } fn add_name_status(text: &str, statuses: &mut HashMap) { - for line in text.lines() { - let Some((code, rest)) = line.split_once('\t') else { + // `--no-renames -z` is `status path `, repeated. + let mut fields = text.split('\0'); + while let Some(code) = fields.next() { + if code.is_empty() { continue; + } + let Some(path) = fields.next() else { + break; }; let status = match code.as_bytes().first() { Some(b'A') => "added", @@ -2002,13 +2049,22 @@ fn add_name_status(text: &str, statuses: &mut HashMap) { Some(b'M' | b'T') => "modified", _ => continue, }; - let relative = normalize_diff_path(rest); + let relative = literal_diff_path(path); if !relative.is_empty() { statuses.insert(relative, status); } } } +fn literal_diff_path(path: &str) -> String { + let path = path.trim(); + if path.is_empty() { + String::new() + } else { + path_to_js(Path::new(path)) + } +} + fn normalize_diff_path(path: &str) -> String { let path = path.trim(); if path.is_empty() { @@ -2074,6 +2130,7 @@ fn mark_cached_and_unstaged(root: &Path, files: &mut HashMap) { root, &[ "diff", + "-z", "--relative", "--cached", "--name-only", @@ -2082,8 +2139,8 @@ fn mark_cached_and_unstaged(root: &Path, files: &mut HashMap) { ".", ], ) { - for line in names.lines() { - let relative = normalize_diff_path(line); + for line in names.split('\0') { + let relative = literal_diff_path(line); if !relative.is_empty() { files.entry(relative).or_default().staged = true; } @@ -2093,6 +2150,7 @@ fn mark_cached_and_unstaged(root: &Path, files: &mut HashMap) { root, &[ "diff", + "-z", "--relative", "--name-only", "--no-renames", @@ -2100,8 +2158,8 @@ fn mark_cached_and_unstaged(root: &Path, files: &mut HashMap) { ".", ], ) { - for line in names.lines() { - let relative = normalize_diff_path(line); + for line in names.split('\0') { + let relative = literal_diff_path(line); if !relative.is_empty() { files.entry(relative).or_default().unstaged = true; } @@ -2359,6 +2417,7 @@ fn git_commit_files_for(root: &Path, sha: &str) -> Result, S root, &[ "diff-tree", + "-z", "--no-commit-id", "-r", "--root", @@ -2373,6 +2432,7 @@ fn git_commit_files_for(root: &Path, sha: &str) -> Result, S root, &[ "diff-tree", + "-z", "--no-commit-id", "-r", "--root", @@ -5580,6 +5640,36 @@ fn read_file_base64_sync(path: &str) -> Result { )) } +/// Video containers the chat can stream; mirrors `VIDEO_MIME_TYPES` in the UI. +const STREAMABLE_VIDEO_EXTENSIONS: &[&str] = &[ + "mp4", "m4v", "mov", "webm", "ogv", "avi", "mkv", "mpg", "mpeg", "3gp", "3g2", +]; + +/// Let the asset protocol serve one local video, so a chat can stream it with +/// range requests instead of reading the whole file into memory. Only that +/// file, and only a video container, joins the scope. Returns the canonical +/// path the caller should load. +#[tauri::command] +pub fn allow_video_stream(app: AppHandle, path: String) -> Result { + let path = expand_home(&path) + .canonicalize() + .map_err(|e| e.to_string())?; + let video = is_streamable_video(&path); + if !video || !path.is_file() { + return Err("Not a video file".into()); + } + app.asset_protocol_scope() + .allow_file(&path) + .map_err(|e| e.to_string())?; + Ok(path.to_string_lossy().into_owned()) +} + +fn is_streamable_video(path: &std::path::Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| STREAMABLE_VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) +} + /// Read a file as raw bytes for the image viewer. /// /// Returns an `ipc::Response`, which reaches the webview as an ArrayBuffer, so @@ -6165,6 +6255,22 @@ pub async fn open_path_with_default_app(path: String) -> Result<(), String> { #[cfg(test)] mod tests { use super::*; + + #[test] + fn only_video_containers_can_join_the_stream_scope() { + use std::path::Path; + assert!(is_streamable_video(Path::new("/clips/demo.MP4"))); + assert!(is_streamable_video(Path::new("/clips/demo.webm"))); + for path in [ + "/home/me/.ssh/id_rsa", + "/notes/a.md", + "/shot.png", + "/mp4", + "/a.mp4.txt", + ] { + assert!(!is_streamable_video(Path::new(path)), "{path}"); + } + } use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; @@ -7353,6 +7459,41 @@ mod tests { assert_eq!(untracked.deletions, 0); } + #[test] + fn git_diff_index_keeps_literal_non_ascii_paths() { + let dir = tmp("git-diff-quoted-path"); + if !init_git_commit(&dir.0, &[("한글.md", "before\n"), ("my file.txt", "one\n")]) { + return; + } + // Default quoting would otherwise hand the Changes view a C-escaped name. + assert!(git(&dir.0, &["config", "core.quotePath", "true"])); + std::fs::write(dir.0.join("한글.md"), "before\nafter\n").unwrap(); + std::fs::write(dir.0.join("my file.txt"), "one\ntwo\n").unwrap(); + + let index = git_diff_index_for(&dir.0); + let hangul = index + .files + .iter() + .find(|file| file.relative == "한글.md") + .unwrap(); + assert_eq!(hangul.status, "modified"); + assert_eq!(hangul.additions, 1); + assert!(hangul.unstaged); + assert!(!hangul.relative.contains('\\')); + + let spaced = index + .files + .iter() + .find(|file| file.relative == "my file.txt") + .unwrap(); + assert_eq!(spaced.status, "modified"); + assert_eq!(spaced.additions, 1); + + let diff = git_file_diff_for(&dir.0, "한글.md", false).unwrap(); + assert_eq!(diff.relative, "한글.md"); + assert!(diff.current.contains("after")); + } + #[test] fn git_diff_files_keep_paths_relative_to_nested_workspace() { let dir = tmp("git-diff-nested-workspace"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a562c88f1d..67f12b2148 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -253,6 +253,9 @@ pub fn run() { .with_filter(window::is_workspace_window) .build(), ) + .register_uri_scheme_protocol(artifacts::HTML_SCHEME, |ctx, request| { + artifacts::serve_html(ctx.app_handle(), &request) + }) .manage(harness::HarnessHost::new()) .manage(pty::PtyHost::new()) .manage(remote::RemoteConnections::default()) @@ -435,6 +438,7 @@ pub fn run() { fs::inspect_paths, fs::read_file_base64, fs::read_binary_file, + fs::allow_video_stream, fs::write_attachment, fs::save_generated_image, fs::delete_generated_images, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 46e96fece9..88af356c5b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "MonoCode", - "version": "0.12.0", + "version": "0.13.0", "identifier": "com.monocode.desktop", "build": { "beforeDevCommand": "npm run dev", @@ -28,8 +28,8 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost asset: http://asset.localhost https://asset.localhost; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src 'none'", - "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost asset: http://asset.localhost https://asset.localhost http://localhost:1420 ws://localhost:1420 http://127.0.0.1:1420 ws://127.0.0.1:1420; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src 'none'", + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost asset: http://asset.localhost https://asset.localhost; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src monoartifact: http://monoartifact.localhost", + "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost asset: http://asset.localhost https://asset.localhost http://localhost:1420 ws://localhost:1420 http://127.0.0.1:1420 ws://127.0.0.1:1420; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src monoartifact: http://monoartifact.localhost", "dangerousDisableAssetCspModification": ["style-src"], "assetProtocol": { "enable": true, diff --git a/src/app/App.tsx b/src/app/App.tsx index ad6eea4a56..d2d9cdc13e 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -18,6 +18,7 @@ import { acceptQuickLaunch } from "./model/quickLaunchSession"; import { useWorkspaceNavigation } from "./hooks/useWorkspaceNavigation"; import { useIdleSessionDetach } from "./hooks/useIdleSessionDetach"; import { HarnessEventQueue } from "./model/harnessFlush"; +import { updateSessionState } from "./model/sessionState"; import { handleAgentApp, canAccessAgentAppProject, @@ -91,12 +92,18 @@ import { WhatsNewDialog } from "./shell/WhatsNewDialog"; import { ProviderSignInDialog } from "../features/sessions/ui/ProviderSignInDialog"; import { TitleBar, type Tab as TitleTab } from "./shell/TitleBar"; import { WindowControls } from "./shell/WindowControls"; -import { MonoDetails } from "../features/monos/ui/MonoDetails"; -import { MonoActivityPanel } from "../features/monos/ui/MonoActivityPanel"; -import { MonoSessionsPanel } from "../features/monos/ui/MonoSessionsPanel"; import { + MonoOverview, + type MonoOverviewTab, +} from "../features/monos/ui/MonoOverview"; +import { MonoSettingsDialog } from "../features/monos/ui/MonoSettingsDialog"; +import { MonoActivityView } from "../features/monos/ui/MonoActivityView"; +import { + launchedSessionState, monoSpawnedSessions, recordMonoSpawnedSession, + type LaunchedSessionState, + type LaunchedSessionView, } from "../features/monos/model/monoSpawnedSessions"; import { groupMonoTurns } from "../features/sessions/model/transcriptActivity"; import { @@ -129,6 +136,7 @@ import { renameWorktreeBranch, sessionInWorktree, temporaryWorktreeBranchName, + worktreeDeletedExternally, worktreeSessionIds, type Worktree, } from "../features/source-control/model/worktrees"; @@ -334,6 +342,7 @@ import { import { isEditTool } from "../integrations/harness/core/preview"; import { sessionEditPaths } from "../features/sessions/model/checkpointEdits"; +import { useSessionChangeTotals } from "../features/sessions/ui/SessionReview"; import { createEditedResendAttempt, createEditedResendCoordinator, @@ -460,6 +469,7 @@ import { type RuntimeMode, type PlanStatus, type SecondOpinionMeta, + type MonoSpawnedSession, type Session, type UsageLimit, type WorkspaceMode, @@ -552,11 +562,13 @@ import { findMono, isMonoSession, listMonos, + loadOpenMono, monoForSession, monoLook, monosSnapshot, monoState, removeMono, + saveOpenMono, subscribeMonos, type MonoState, } from "../features/monos/model/mono"; @@ -577,6 +589,7 @@ import { habitPostsContext, habitRunMono, isHabitRun, + noteHabitRunLaunch, relayApproval, takeRelayedApproval, loadHabits, @@ -617,15 +630,12 @@ import { import { getArtifact, saveArtifact, + artifactCards, recordArtifactCard, removeArtifactCard, ARTIFACT_DELETED_EVENT, } from "../features/artifacts/artifacts"; -import { - MonoChangesPanel, - type MonoChangesRequest, -} from "../features/monos/ui/MonoChangesPanel"; -import { ArtifactPanel } from "../features/artifacts/ui/ArtifactPanel"; +import type { MonoChangesRequest } from "../features/monos/ui/MonoChangesView"; import { claimDueAutomations, listAutomations, @@ -673,6 +683,7 @@ import { findRemoteSessionTab } from "../features/connections/model/remoteSessio import { remotePath, remoteProjectFor, + remoteSessionGitCwd, } from "../features/connections/model/remoteProjects"; import type { HostSession } from "../features/connections/model/protocol"; import { AddRemoteProjectDialog } from "../features/connections/ui/AddRemoteProjectDialog"; @@ -1091,37 +1102,27 @@ function Workspace({ () => windowTransfer?.activeTabId ?? resumed?.activeTabId ?? seed.tab.id, ); const [monoViewId, setMonoViewId] = useState(null); - const [monoDetailsOpen, setMonoDetailsOpen] = useState(false); - const [monoArtifact, setMonoArtifact] = useState<{ - sessionId: string; - id: string; - } | null>(null); - const monoArtifactOpener = useRef(null); - const onOpenMonoArtifact = useCallback((sessionId: string, id: string) => { - monoArtifactOpener.current = - document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - setMonoDetailsOpen(false); - setMonoActivity(null); - setMonoSessions(null); - setMonoChanges(null); - setMonoArtifact({ sessionId, id }); - }, []); - const onCloseMonoArtifact = useCallback(() => { - setMonoArtifact(null); - monoArtifactOpener.current?.focus(); - }, []); - const [monoChanges, setMonoChanges] = useState<{ - sessionId: string; - cwd: string; - request: MonoChangesRequest; - } | null>(null); - const monoChangesOpener = useRef(null); - const onCloseMonoChanges = useCallback(() => { - setMonoChanges(null); - monoChangesOpener.current?.focus(); + // A Mono opens with its overview beside the chat until it is hidden. + const [monoOverviewOpen, setMonoOverviewOpen] = useState(true); + const [monoOverviewTab, setMonoOverviewTab] = + useState("sessions"); + const monoOverviewOpenRef = useRef(monoOverviewOpen); + monoOverviewOpenRef.current = monoOverviewOpen; + const monoOverviewTabRef = useRef(monoOverviewTab); + monoOverviewTabRef.current = monoOverviewTab; + // The Mono whose settings dialog is open; another Mono's view closes it. + const [monoSettingsFor, setMonoSettingsFor] = useState(null); + // A document opened from the chat shows on the overview's Documents tab, + // the same page the tab opens it on. + const onOpenMonoArtifact = useCallback((_sessionId: string, id: string) => { + setMonoShownSession(null); + setMonoOverviewDocument(id); + setMonoOverviewTab("documents"); + setMonoOverviewOpen(true); }, []); + // What the overview's Changes tab was last asked to show. + const [monoChangesRequest, setMonoChangesRequest] = + useState({}); useEffect(() => { const listening = listen( ARTIFACT_DELETED_EVENT, @@ -1134,7 +1135,7 @@ function Workspace({ sessionsRef.current = updated; setSessions(updated); } - setMonoArtifact((current) => (current?.id === id ? null : current)); + setMonoOverviewDocument((current) => (current === id ? null : current)); }, ); return () => { @@ -1143,30 +1144,39 @@ function Workspace({ }, []); const [monoActivity, setMonoActivity] = useState(null); - const [monoSessions, setMonoSessions] = - useState(null); + // A launched session or a document open over the overview's list. Hiding + // the overview leaves them, so it opens on its list again. + const [monoShownSession, setMonoShownSession] = useState(null); + const [monoOverviewDocument, setMonoOverviewDocument] = useState< + string | null + >(null); + useEffect(() => { + if (monoOverviewOpen) return; + setMonoShownSession(null); + setMonoOverviewDocument(null); + }, [monoOverviewOpen]); + // Whether the shown session, rather than the Mono's chat, holds focus. + const [monoShownSessionFocused, setMonoShownSessionFocused] = useState(false); + const shownMonoSessionId = + monoViewId && monoOverviewOpen && monoShownSession + ? monoShownSession + : undefined; + const shownMonoSessionIdRef = useRef(shownMonoSessionId); + shownMonoSessionIdRef.current = shownMonoSessionId; + // A turn's activity shows on the overview's Activity tab; picking the shown + // one again clears it. const onShowMonoActivity = useCallback( (sessionId: string, turnId: string, blocks: Block[]) => { - setMonoDetailsOpen(false); - setMonoArtifact(null); - setMonoChanges(null); - setMonoSessions(null); + const shown = + monoOverviewOpenRef.current && monoOverviewTabRef.current === "activity"; + setMonoShownSession(null); + setMonoOverviewDocument(null); + setMonoOverviewTab("activity"); + setMonoOverviewOpen(true); setMonoActivity((previous) => - previous?.sessionId === sessionId && previous.turnId === turnId - ? null - : { sessionId, turnId, blocks }, - ); - }, - [], - ); - const onShowMonoSessions = useCallback( - (sessionId: string, turnId: string, blocks: Block[]) => { - setMonoDetailsOpen(false); - setMonoArtifact(null); - setMonoChanges(null); - setMonoActivity(null); - setMonoSessions((previous) => - previous?.sessionId === sessionId && previous.turnId === turnId + shown && + previous?.sessionId === sessionId && + previous.turnId === turnId ? null : { sessionId, turnId, blocks }, ); @@ -1179,11 +1189,9 @@ function Workspace({ const closeMonoView = useCallback(() => { monoViewRequest.current += 1; monoViewIdRef.current = null; + saveOpenMono(undefined); setMonoViewId(null); setMonoActivity(null); - setMonoSessions(null); - setMonoArtifact(null); - setMonoChanges(null); }, []); const [composerFocused, setComposerFocused] = useState(() => { if (windowTransfer) return true; @@ -1478,6 +1486,8 @@ function Workspace({ const foregroundSurfaceRef = useRef<{ workspaceVisible: boolean; standaloneSessionId?: string; + /** A session shown beside the Mono's chat. */ + sideSessionId?: string; }>({ workspaceVisible: true }); foregroundSurfaceRef.current = { workspaceVisible: @@ -1495,6 +1505,14 @@ function Workspace({ !settingsOpen ? (monoViewId ?? undefined) : undefined, + sideSessionId: + !inboxViewOpen && + !searchViewOpen && + !notesViewOpen && + !automationsViewOpen && + !settingsOpen + ? shownMonoSessionId + : undefined, }; const notesViewOpenRef = useRef(notesViewOpen); notesViewOpenRef.current = notesViewOpen; @@ -1593,6 +1611,7 @@ function Workspace({ // Standalone conversations live outside the workspace tab tree. return ( foregroundSurfaceRef.current.standaloneSessionId === sessionId || + foregroundSurfaceRef.current.sideSessionId === sessionId || (foregroundSurfaceRef.current.workspaceVisible && !!tab && (leafIds(tab.layout).includes(sessionId) || @@ -1606,15 +1625,23 @@ function Workspace({ ); }, (batches) => { - const prev = sessionsRef.current; - const next = prev.map((session) => { - const events = batches.get(session.id); - return events ? applyHarnessEvents(session, events) : session; - }); - if (!next.some((session, index) => session !== prev[index])) return; - sessionsRef.current = next; - syncDockBadge(next); - setSessions(next); + const previous = sessionsRef.current; + const next = updateSessionState( + sessionsRef, + setSessions, + (current) => { + const updated = current.map((session) => { + const events = batches.get(session.id); + return events ? applyHarnessEvents(session, events) : session; + }); + return updated.some( + (session, index) => session !== current[index], + ) + ? updated + : current; + }, + ); + if (next !== previous) syncDockBadge(next); }, ), ); @@ -1920,17 +1947,33 @@ function Workspace({ ? active.branch || gitCwdBranches?.current || undefined : undefined; const remoteFilesProject = remoteProjectFor(sidebarCwd); + // The host checkout the remote tab is aimed at — the attached session's + // work path or a worktree picked before the first message. + const remoteTabCheckout = remoteFilesProject + ? remoteTabCwd(sidebarCwd, active?.id) + : undefined; const filesCwd = remoteFilesProject ? isRemoteProjectPath(gitCwd) ? gitCwd : remotePath( remoteFilesProject.environmentId, - remoteTabCwd(sidebarCwd, active?.id) ?? + remoteTabCheckout ?? (gitCwd && gitCwd !== sidebarCwd ? gitCwd : remoteFilesProject.cwd), ) : gitCwd; - const gitCwdRef = useRef(filesCwd); - gitCwdRef.current = filesCwd; + // Git follows the session's own work path (a linked worktree included), + // never the terminal-following files cwd: a remote session working in a + // separate worktree diffed the main checkout and showed "No file changes". + const gitRootCwd = remoteFilesProject + ? remoteSessionGitCwd( + remoteFilesProject, + gitCwd, + sidebarCwd, + remoteTabCheckout, + ) + : gitCwd; + const gitCwdRef = useRef(gitRootCwd); + gitCwdRef.current = gitRootCwd; const projectBranches = useProjectBranches( sidebarCwd, Boolean(sidebarCwd) && sidebarCwd !== "~", @@ -2066,7 +2109,9 @@ function Workspace({ const activeSessionId = inboxViewOpen ? inboxAskPortal?.sessionId - : (monoViewSession?.id ?? active?.id); + : shownMonoSessionId && monoShownSessionFocused + ? shownMonoSessionId + : (monoViewSession?.id ?? active?.id); const activeSessionIdRef = useRef(activeSessionId); activeSessionIdRef.current = activeSessionId; @@ -2195,6 +2240,7 @@ function Workspace({ monoViewId, inboxViewOpen, inboxAskPortal?.sessionId, + shownMonoSessionId, searchViewOpen, notesViewOpen, automationsViewOpen, @@ -2614,14 +2660,16 @@ function Workspace({ const onSelectProviderAccount = useCallback( (provider: ProviderAccountProvider, accountId: string) => { - if (!active || active.harness !== provider) return; - const currentId = active.providerAccountId ?? DEFAULT_PROVIDER_ACCOUNT_ID; - if (currentId === accountId) return; + const current = sessionsRef.current.find( + (session) => session.id === active?.id, + ); + if (!current || current.harness !== provider) return; - if (active.blocks.length === 0 && !active.busy) { - setSessions((current) => - current.map((session) => - session.id === active.id + if (current.blocks.length === 0 && !current.busy) { + if (current.providerAccountId === accountId) return; + updateSessionState(sessionsRef, setSessions, (sessions) => + sessions.map((session) => + session.id === current.id ? { ...session, providerAccountId: accountId } : session, ), @@ -2629,25 +2677,32 @@ function Workspace({ return; } + const currentId = + current.providerAccountId ?? DEFAULT_PROVIDER_ACCOUNT_ID; + if (currentId === accountId) return; + // Provider thread ids are account-owned. Keep the current conversation // pinned to its account and open a clean one for the selected profile. const session = { ...newSession( - active.harness, - active.cwd, - active.model, - active.runtimeMode, - active.modelSettings, + current.harness, + current.cwd, + current.model, + current.runtimeMode, + current.modelSettings, ), providerAccountId: accountId, }; const tab = newTab(session.id); - setSessions((current) => [...current, session]); - appendTab(tab, active.cwd); + updateSessionState(sessionsRef, setSessions, (sessions) => [ + ...sessions, + session, + ]); + appendTab(tab, current.cwd); setActiveTabId(tab.id); setComposerFocused(true); }, - [active, appendTab], + [active?.id, appendTab], ); const onOpenWhatsNew = useCallback((version: string) => { @@ -2691,11 +2746,7 @@ function Workspace({ setActiveTabId(tab.id); setComposerFocused(true); return session.id; - }, [ - appendTab, - sessionDefaults?.runtimeMode, - sidebarCwd, - ]); + }, [appendTab, sessionDefaults?.runtimeMode, sidebarCwd]); const onSelectRemoteSession = useCallback( (project: string, remoteSessionId: string) => { @@ -2979,29 +3030,6 @@ function Workspace({ onOpenTerminal(terminalCwd); }, [terminalCwd, onOpenTerminal]); - const onShowProjectTerminal = useCallback(() => { - leaveCoveringMono(); - const dock = findProjectTerminal(projectTerminalsRef.current, projectCwd); - if (dock && dock.pane.files.length > 0) { - if (!dock.open) { - setProjectTerminals((prev) => - mapProjectTerminal(prev, projectCwd, (entry) => - withDockOpen(entry, true), - ), - ); - } - focusProjectTerminal(); - return; - } - onOpenTerminal(terminalCwd); - }, [ - terminalCwd, - focusProjectTerminal, - leaveCoveringMono, - onOpenTerminal, - projectCwd, - ]); - const onNewTerminalInSession = useCallback( (sessionId: string) => { const session = sessionsRef.current.find( @@ -4021,10 +4049,25 @@ function Workspace({ const onFocusPane = useCallback( (paneId: string) => { if (paneId === monoViewIdRef.current) { + setMonoShownSessionFocused(false); + setProjectTerminalFocused(false); + setComposerFocused(true); + return; + } + if (paneId === shownMonoSessionIdRef.current) { + setMonoShownSessionFocused(true); setProjectTerminalFocused(false); setComposerFocused(true); return; } + // Over the Mono, only its sidebar holds panes outside the workspace: a + // launched session sliding away as another opens. Its late focus must + // not close the Mono and send that session to its project. + if ( + monoViewIdRef.current && + !tabsRef.current.some((tab) => leafIds(tab.layout).includes(paneId)) + ) + return; closeMonoView(); if ( tabsRef.current.find((tab) => tab.id === activeTabIdRef.current) @@ -4107,38 +4150,27 @@ function Workspace({ [activeTabId], ); - // A Mono view covers the workspace, so its session changes open beside the - // chat instead of in a project tab hidden behind it. - const openMonoChanges = useCallback( - ( - session: { sessionId: string; cwd: string }, - request: MonoChangesRequest, - ) => { - monoChangesOpener.current = - document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - setMonoDetailsOpen(false); - setMonoActivity(null); - setMonoSessions(null); - setMonoArtifact(null); - setMonoChanges({ ...session, request }); - }, - [], - ); + // A Mono view covers the workspace, so its session changes open on the + // overview's Changes tab instead of in a project tab hidden behind it. + const openMonoChanges = useCallback((request: MonoChangesRequest) => { + setMonoShownSession(null); + setMonoOverviewDocument(null); + setMonoChangesRequest(request); + setMonoOverviewTab("changes"); + setMonoOverviewOpen(true); + }, []); const onOpenMonoDiff = useCallback( (path?: string, session?: { sessionId: string; cwd: string }) => { if (!session) { onOpenDiff(path); return; } - openMonoChanges(session, { path, tab: "changes" }); + openMonoChanges({ path }); }, [onOpenDiff, openMonoChanges], ); const onCommitMonoChanges = useCallback( - (session: { sessionId: string; cwd: string }) => - openMonoChanges(session, { tab: "commit" }), + () => openMonoChanges({}), [openMonoChanges], ); @@ -4663,7 +4695,6 @@ function Workspace({ const onOpenMono = useCallback( async (monoId: string) => { setMonoActivity(null); - setMonoSessions(null); workspaceNavigation.cancel(); const request = ++monoViewRequest.current; setSearchViewOpen(false); @@ -4681,7 +4712,15 @@ function Workspace({ }, }); if (!session || request !== monoViewRequest.current) return; + // What the last Mono had open over its overview is its own; another + // Mono opens on its list. + if (monoViewIdRef.current !== session.id) { + setMonoShownSession(null); + setMonoShownSessionFocused(false); + setMonoOverviewDocument(null); + } monoViewIdRef.current = session.id; + saveOpenMono(monoId); setMonoViewId(session.id); setProjectTerminalFocused(false); setComposerFocused(true); @@ -4690,6 +4729,18 @@ function Workspace({ [ensureOpenSession, workspaceNavigation.cancel], ); + // A Mono open when the app last closed opens again; a moved window keeps + // what it carried. + const reopenedMono = useRef(false); + useEffect(() => { + if (reopenedMono.current) return; + reopenedMono.current = true; + if (windowTransfer || !monosEnabled) return; + const monoId = loadOpenMono(); + if (monoId && findMono(monoId)) void onOpenMono(monoId); + else if (monoId) saveOpenMono(undefined); + }, [monosEnabled, onOpenMono, windowTransfer]); + /** The shortcut opens the last Mono, or the first on the rail. */ const onToggleMono = useCallback(() => { if (monoViewIdRef.current) { @@ -4706,8 +4757,9 @@ function Workspace({ /** A new Mono opens straight away, ready to be told what it works on. */ const onCreateMono = useCallback(() => { - setMonoDetailsOpen(true); - void onOpenMono(createMono().id); + const monoId = createMono().id; + setMonoSettingsFor(monoId); + void onOpenMono(monoId); }, [onOpenMono]); const onResetMono = useCallback( @@ -4839,6 +4891,44 @@ function Workspace({ [ensureOpenSession, closeMonoView, setSidebarTab, onSelectHistorySession], ); + // A launched session opens over the overview, where it can be followed, + // answered or stopped without leaving the chat. Back lands on every session + // the Mono started; opening the one already shown hides it again. + const showMonoLaunchedSession = useCallback( + async (monoSessionId: string, launchedId: string) => { + if (shownMonoSessionIdRef.current === launchedId) { + setMonoShownSession(null); + return; + } + // Load it first, then switch in one step: the overview goes straight + // from what it shows to this session, never through the list. + const session = await ensureOpenSession(launchedId).catch(() => null); + if (monoViewIdRef.current !== monoSessionId) return; + setMonoOverviewTab("sessions"); + setMonoOverviewDocument(null); + setMonoOverviewOpen(true); + // One that is gone leaves the sessions list open, which says so. + if (!session) { + setMonoShownSession(null); + return; + } + setMonoShownSession(launchedId); + setMonoShownSessionFocused(true); + setProjectTerminalFocused(false); + setComposerFocused(true); + }, + [ensureOpenSession], + ); + const onOpenMonoSession = useCallback( + ( + monoSessionId: string, + _turnId: string, + _blocks: Block[], + launchedId: string, + ) => showMonoLaunchedSession(monoSessionId, launchedId), + [showMonoLaunchedSession], + ); + const openReminderSession = useCallback( async (sessionId: string) => { const session = await ensureOpenSession(sessionId); @@ -7964,16 +8054,18 @@ function Workspace({ } if (turnGen.current.get(sessionId) !== gen) return; if (wrap) { - setSessions((prev) => - prev.map((s) => { - if (s.id !== sessionId) return s; - const ready = isPreparingHandoff(s) - ? completeHandoff(s, wrap.text) - : s; - // A command owns its arguments; deliver the recap with the next chat prompt. - return rawCommand ? ready : consumeHandoff(ready); - }), - ); + // Settle the ref too: the flush in `finally` rebuilds sessions from + // it, and a stale ref would drop the consumed flag and resend the + // recap with every later turn. + sessionsRef.current = sessionsRef.current.map((s) => { + if (s.id !== sessionId) return s; + const ready = isPreparingHandoff(s) + ? completeHandoff(s, wrap.text) + : s; + // A command owns its arguments; deliver the recap with the next chat prompt. + return rawCommand ? ready : consumeHandoff(ready); + }); + setSessions(sessionsRef.current); } buildSucceeded = true; } catch (error: unknown) { @@ -8053,6 +8145,27 @@ function Workspace({ : finalized; }), ); + // A worktree removed outside the app fails every turn. Detach the + // session as an in-app removal would, so it can pick a new checkout. + const worktreePath = current.worktreeCwd; + if (providerFailureSeen && worktreePath) { + void worktreeDeletedExternally(worktreePath) + .then((deleted) => { + if (!deleted || switchingWorktrees.current.has(sessionId)) + return; + // Through the ref too, or a pending event flush puts it back. + sessionsRef.current = sessionsRef.current.map((s) => + s.id === sessionId && + !s.busy && + !s.worktreeRemoved && + s.worktreeCwd === worktreePath + ? detachSessionWorktree(s, s.cwd, worktreePath) + : s, + ); + setSessions(sessionsRef.current); + }) + .catch(() => undefined); + } // Next tick: the flush above has rendered by then, so the banner // quotes the reply's final text rather than the previous batch. window.setTimeout(() => { @@ -8224,7 +8337,7 @@ function Workspace({ ); setSessions(sessionsRef.current); }, - post: (monoId, habit, text, name, artifacts) => + post: (monoId, habit, text, name, artifacts, launches) => appendToMono( monoId, { @@ -8233,6 +8346,7 @@ function Workspace({ text, monoHabit: { id: habit.id, name: habit.name, at: Date.now() }, ...(artifacts?.length ? { artifactCards: artifacts } : {}), + ...(launches?.length ? { monoSpawnedSessions: launches } : {}), }, (posted) => { monoReplies.noteReply(monoId); @@ -8473,6 +8587,7 @@ function Workspace({ deliveryId: string, placement?: AppSessionPlacement, onSettled?: (outcome: ControlOutcome) => void, + openTab?: boolean, ) => acceptQuickLaunch( launch, @@ -8543,6 +8658,7 @@ function Workspace({ flushSync(() => onSaveDraft(id, prompt, attachments, requestId)), }, placement, + openTab, ), [appendTab, submitSession, onSaveDraft], ); @@ -10579,6 +10695,28 @@ function Workspace({ ? groupMonoTurns(source.blocks) : []; const sourceTurnId = sourceTurns[sourceTurns.length - 1]?.[0]?.id; + const rememberSession = (entry: MonoSpawnedSession) => { + // A habit run is thrown away when it ends; its launches go on the + // report it posts to the Mono's chat instead. + if (isHabitRun(source.id)) { + noteHabitRunLaunch(source.id, entry); + return; + } + if (!sourceTurnId) return; + const next = sessionsRef.current.map((session) => + session.id === source.id + ? recordMonoSpawnedSession(session, sourceTurnId, entry) + : session, + ); + if ( + next.every( + (session, index) => session === sessionsRef.current[index], + ) + ) + return; + sessionsRef.current = next; + setSessions(next); + }; const monitorCompletion = ( monoId: string, sessionId: string, @@ -10630,32 +10768,18 @@ function Workspace({ { start: async (launch, id, placement, notifyMonoId) => { const rememberLaunch = () => { - if (!sourceTurnId) return; const created = sessionsRef.current.find((session) => session.id === id) ?? existing; - const next = sessionsRef.current.map((session) => - session.id === source.id - ? recordMonoSpawnedSession(session, sourceTurnId, { - sessionId: id, - cwd: created?.cwd ?? launch.cwd, - title: - created?.title ?? launch.prompt.trim().slice(0, 300), - harness: created?.harness ?? launch.harness, - model: - created?.model ?? - resolveModel(launch.harness, launch.model).id, - }) - : session, - ); - if ( - next.every( - (session, index) => session === sessionsRef.current[index], - ) - ) - return; - sessionsRef.current = next; - setSessions(next); + rememberSession({ + sessionId: id, + cwd: created?.cwd ?? launch.cwd, + title: created?.title ?? launch.prompt.trim().slice(0, 300), + harness: created?.harness ?? launch.harness, + model: + created?.model ?? + resolveModel(launch.harness, launch.model).id, + }); }; const open = sessionsRef.current.find( (session) => session.id === id, @@ -10696,11 +10820,14 @@ function Workspace({ ) : undefined; try { + // A Mono's sessions open from its chat, not as workspace + // tabs, unless it places one beside an open pane. await launchQuickSessionRef.current( launch, id, placement, onSettled, + !isMonoSession(source.id) || !!placement, ); rememberLaunch(); onSettled?.accept(); @@ -10766,6 +10893,18 @@ function Workspace({ orchestrator.run(id) ) throw new Error("Session is unavailable in this project"); + const rememberFollowUp = () => { + const current = + sessionsRef.current.find((session) => session.id === id) ?? + target; + rememberSession({ + sessionId: id, + cwd: current.cwd, + title: current.title, + harness: current.harness, + model: current.model, + }); + }; const previous = target.blocks.find( (block) => block.appRequestId === requestId, ); @@ -10776,6 +10915,7 @@ function Workspace({ ); if (previous.draft) throw new Error("Request ID belongs to an unsent draft"); + rememberFollowUp(); return { alreadySubmitted: true }; } if (target.busy) @@ -10811,6 +10951,7 @@ function Workspace({ onSettled?.discard(); throw new Error("Session could not accept the follow-up"); } + rememberFollowUp(); onSettled?.accept(); return { alreadySubmitted: false }; }, @@ -12363,31 +12504,211 @@ function Workspace({ monoActivity, monoViewSession, ); - const selectedMonoSessions = resolveMonoActivity( - monoSessions, - monoViewSession, + // The chat marks the turn whose activity the overview is showing. + const monoActivityShownTurnId = + monoOverviewOpen && monoOverviewTab === "activity" + ? selectedMonoActivity?.turnId + : undefined; + const monoViewBlocks = monoViewSession?.blocks; + const monoViewBusy = !!monoViewSession?.busy; + const monoViewCwd = monoViewSession ? sessionWorkCwd(monoViewSession) : ""; + const monoEditedPaths = useMemo( + () => + monoViewBlocks && !monoViewBusy + ? sessionEditPaths(monoViewBlocks) + : undefined, + [monoViewBlocks, monoViewBusy], + ); + // The session's changes, on the overview's tab instead of under a message. + const monoChangeTotals = useSessionChangeTotals( + monoViewSession?.id ?? "", + monoViewCwd, + { + enabled: !monoViewSession?.worktreeRemoved, + busy: monoViewBusy, + editedPaths: monoEditedPaths, + }, + ); + const monoLaunches = useMemo( + () => (monoViewBlocks ? monoSpawnedSessions(monoViewBlocks) : []), + [monoViewBlocks], + ); + const monoDocuments = useMemo( + () => (monoViewBlocks ? artifactCards(monoViewBlocks) : []), + [monoViewBlocks], + ); + const monoActivityView = + monoViewSession && selectedMonoActivity ? ( + + onApproval(monoViewSession.id, requestId, decision) + } + onOpenFile={onOpenFile} + onOpenDiff={onOpenDiff} + /> + ) : null; + // Status for the cards of the sessions the open Mono launched, rebuilt only + // when one changes state or title rather than on every streamed token. One + // that is no longer open cannot be running, so it reads as done. + // A card names its session as the sidebar does. One that is not open has + // its saved title fetched, rather than the prompt it was launched with, so + // opening it does not rename the card. + const [storedLaunchTitles, setStoredLaunchTitles] = useState< + ReadonlyMap + >(() => new Map()); + const unopenedLaunchesKey = JSON.stringify( + monoLaunches.flatMap((launch) => + storedLaunchTitles.has(launch.sessionId) || + sessions.some((entry) => entry.id === launch.sessionId) + ? [] + : [launch.sessionId], + ), + ); + useEffect(() => { + const ids: string[] = JSON.parse(unopenedLaunchesKey); + if (ids.length === 0) return; + let current = true; + void Promise.all(ids.map((id) => getSession(id).catch(() => null))).then( + (stored) => { + if (!current) return; + setStoredLaunchTitles((previous) => { + const next = new Map(previous); + stored.forEach((session, index) => + next.set(ids[index], session?.title ?? ""), + ); + return next; + }); + }, + ); + return () => { + current = false; + }; + }, [unopenedLaunchesKey]); + const monoLaunchesKey = JSON.stringify( + monoLaunches.map((launch) => { + const live = sessions.find((entry) => entry.id === launch.sessionId); + return live + ? [launch.sessionId, launchedSessionState(live), live.title] + : [ + launch.sessionId, + "done", + storedLaunchTitles.get(launch.sessionId) || launch.title, + ]; + }), + ); + const monoLaunchedSessions = useMemo( + () => + new Map( + ( + JSON.parse(monoLaunchesKey) as [ + string, + LaunchedSessionState, + string, + ][] + ).map(([id, state, title]) => [id, { state, title }]), + ), + [monoLaunchesKey], ); - const monoSidebarOpen = - monoDetailsOpen || - !!selectedMonoActivity || - !!selectedMonoSessions || - (monoArtifact?.sessionId === monoViewSession?.id && !!monoArtifact) || - (monoChanges?.sessionId === monoViewSession?.id && !!monoChanges); - const monoDetailsPanel = + const monoShownSessionPane = shownMonoSessionId + ? sessions.find((session) => session.id === shownMonoSessionId) + : undefined; + const monoShownSessionActive = + !!monoShownSessionPane && monoShownSessionFocused; + const monoShownSessionView = monoShownSessionPane + ? { + sessionId: monoShownSessionPane.id, + title: sessionDisplayTitle( + monoShownSessionPane.title, + monoShownSessionPane.harness, + ), + pane: ( +