diff --git a/CHANGELOG.md b/CHANGELOG.md index b3566351..2437867a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,65 +2,29 @@ ## Unreleased -- Clone GitHub repo dialog: row clicks now select a repo instead of cloning immediately and stamp the repo URL into the search input; the local filter normalizes URL queries to the `owner/repo` slug so the list stays narrowed after selection. An explicit Clone button at the bottom-right stays disabled until both a repo (or URL/slug query) and a destination folder are set. Destination folder moved below the result list. While cloning, the picker stays visible (controls disabled) and a compact `max-h-[10rem]` terminal-style preview box appears above the footer streaming git's `--progress` stderr (newest line at the top, capped at 200 lines) via a new `clone-progress` Tauri event from `clone_repo_with_progress` in `github.rs`; the Clone button reads "Cloning..." while git is running -- Fix `Clone failed: git clone failed: … the repository exists.` for users without SSH set up. GitHub clones now go over HTTPS (`https://github.com//.git`) so `gh auth login`'s credential helper supplies the OAuth token instead of relying on user SSH keys. The unused `resolve_repo_clone_urls` + its `gh repo view` roundtrip are gone. A failed clone now also `fs::remove_dir_all`s the partial worktree git left behind, so retrying doesn't immediately hit "Destination already exists" -- Clone GitHub repo dialog no longer scrolls vertically when the destination folder's autocomplete dropdown opens. Overriding the base `Dialog`'s `overflow-y-auto` with `!overflow-visible` lets the absolute-positioned `PathAutocomplete` list pop out below the dialog instead of clipping (which was the trigger that forced the dialog into a scroll container). `PathAutocomplete`'s dropdown also tightened from `max-h-56` to `max-h-40` so it stays compact when there are many sibling folders -- Clone GitHub repo dialog: don't re-fetch the picked repo from GitHub after selecting it — the search input is stamped to the repo URL but the debounced remote lookup short-circuits when the query equals the picked repo's URL or slug. Selected row now uses the same surface as hover (no accent ring), keeping the check icon as the sole "this one's picked" affordance -- Fix `Clone failed: Failed to parse gh repo view output: missing field starCount`. `gh repo view --json` emits `stargazerCount`, not `starCount`; `RemoteRepo.star_count` now accepts both via `#[serde(default, alias = "stargazerCount")]` -- Clone GitHub repo dialog: the inline spinner inside the search input is gone, replaced with shimmer rows in the result list while remote search is in flight; local filter is tokenized and ranked (exact repo-name > prefix > substring > owner > description) so multi-word queries match and best hits surface first -- Clone GitHub repo dialog redesigned for a macOS command-palette feel: roomier modal padding, properly inset search field (icon + text spacing), padded scroll list with rounded hover/selected rows, vertically centred repo rows, stronger name/secondary-text hierarchy, and breathier footer shortcut groups. `Dialog` gained an optional `class` prop for per-modal chrome overrides -- Clone GitHub repo dialog repo suggestions now show the owner's avatar — the organization's logo for org repos, the user's profile photo for personal repos (`/user/repos` now surfaces `owner.avatar_url` / `owner.type`) — falling back to a building icon when no avatar is available -- Clone GitHub repo dialog footer no longer shows the Backspace shortcut hint; empty search no longer closes the dialog on Backspace (use Esc) -- Add **Clone from GitHub** on the first-screen empty state (no projects yet) and **Clone GitHub repo...** in the sidebar `+` menu — both open the same dialog: typeahead over your GitHub repos (sourced via `gh api /user/repos`), destination folder picker, then clone and register the project in one flow. Falls back to a "paste a Git URL" input when `gh` is missing or unauthenticated. -- Start button tooltip now reads "Start project (F5)" instead of inlining the raw start command, so a long `pnpm tauri dev --config ... --features ...` no longer overflows or wraps the OS tooltip. The full command is still visible in project Settings -- `classify_envelope` rustdoc wraps XML/path samples in text-markdown fences so `cargo test --doc` does not compile them as Rust (indented `///` blocks are doctests by default) -- Saving `.gitignore` or `.git/info/exclude` triggers a full refresh of cached file-tree listings for the task so ignored/dimmed state updates immediately (not only the ignore file’s parent folder) -- File tree directory listing uses `read_dir` instead of the ignore walker so `.gitignore` / `.ignore` rules no longer hide entries (ignored items stay visible and dimmed only) -- File tree rows for gitignored paths use reduced opacity (`git check-ignore` batched per directory listing), matching the usual editor convention for ignored vs tracked files -- Running PTY terminals now repaint when you flip dark ↔ light mode. xterm v6's WebGL renderer caches its texture atlas, glyph cache, and rectangle-batch buffers per renderer instance, so setting `term.options.theme` updated the option object but the canvas kept painting the palette baked in at construction — old terminals stayed dark in light mode and new terminals stayed light when switching back. The appearance subscriber now disposes the WebGL addon and reloads a fresh instance after writing the new theme, so xterm runs `setRenderer → _fullRefresh` and every visible cell repaints in the new colors. DOM-renderer callers (BtsLogPane, the inert `Terminal.tsx` log view) were unaffected and stay on the simpler subscribe path. -- Terminal panel chrome now flips with the theme: the outer container was baking in `bg-[#0a0a0a]` so the empty state and any gaps around the xterm canvas stayed black even after switching to light mode. Replaced with `bg-surface-0` so it tracks the active theme like the rest of the panel. -- Side question (`/btw`) for Claude sessions: ask an ephemeral question without polluting the transcript. Type `/btw ` in the composer (auto-completes from the slash-command palette), or - while the agent is streaming and you've scrolled to the bottom of the chat - click the small "Ask sideways /btw" pill that takes the place of the scroll-to-bottom button (same screen real estate, swapped by context). Q/A are not persisted to `output_lines` and never re-enter conversation history on resume; `synthetic` fallbacks are badged distinctly. Wires through a new `ask_side_question` Tauri command on top of the existing control-request plumbing. -- Mid-loop API errors (e.g. ConnectionRefused after tools already ran) now surface a `Continue` button instead of `Retry`. It sends `continue` to the resumed session so Claude picks up from the persisted tool results instead of redoing the whole turn from the original prompt. `Retry in new session` is hidden in this case since it would discard the in-flight progress. Fresh-failure turns (no assistant content yet) keep the original Retry / Retry-in-new-session pair -- Cmd+Alt+Left/Right now cycles between sessions in the current task when viewing a session (wraps at edges). The shortcut still cycles editor tabs when a file is open, mirroring Ctrl+Tab's view-aware behavior -- File edits detected by the worktree watcher now trigger a debounced local git refresh, so status/commits/branch state update automatically as you change code without hitting GitHub -- Local git refresh now uses non-locking read-only git commands, so viewing status no longer rewrites `.git/index` and self-triggers endless `git-local-changed` watcher loops -- Successful in-chat `gh pr create/reopen/close/ready/merge` commands now invalidate GitHub overview immediately, so PR state updates without waiting for process exit or background refresh -- Selected tasks now refresh GitHub overview at session end when an agent likely changed remote state, and dev builds show a live GitHub debug panel with cache/fetch activity -- Session-created pull requests now trigger a scoped GitHub overview refresh when the task is already tracking remote state, so the PR badge/link appears without a manual refresh -- GitHub sidebar/PR/Actions refresh now uses cached Rust-side snapshots with scope-based invalidation, so local git changes stop fan-out refreshing GitHub and Actions/PR data refresh only when their remote state actually changes -- Claude Code sessions now offer a UI / Terminal toggle: Terminal runs `claude --resume ` in a real PTY so you get the unmodified TUI, while still tailing `~/.claude/projects/.../.jsonl` so history, fork, branch, and search keep working with no data gap. The mode is sticky per session, with a Settings default under General → Claude Code, and survives app restarts. Ctrl+D / `/exit` / crash exposes a one-click Reconnect -- Skip the "Do you trust this folder?" Claude TUI prompt for new task worktrees. Verun creates the worktree, picks the branch, and is the one running `claude` in it - the prompt is pure friction in our context. Before spawning the PTY we merge `projects[].hasTrustDialogAccepted = true` and `hasCompletedProjectOnboarding = true` into `~/.claude.json` (atomic temp + rename, mutex-serialized to avoid clobbering concurrent spawns, idempotent so re-opens are no-ops, refuses to overwrite a corrupt config so user settings stay safe). Claude has no CLI flag for this; the JSON merge is the only path -- Slash commands run in terminal mode now show in the UI chat as a clean `/ ` line (e.g. `/adapt wassup`) instead of being dropped or leaking as raw XML. Skill bodies and bare `` post-execution envelopes are still hidden since the model needs them but the user doesn't. -- Filter Claude Code's local-command envelopes (``, ``, ``, etc.) out of the UI chat. The TUI injects them as fake `user` messages so its model can see what slash commands the human ran in terminal mode (`/model`, `/exit`, `/clear`, ...) - they're scaffolding for the model, not user-facing content. They were leaking into UI mode as raw XML + ANSI escapes whenever the user toggled back. Filter at the JSONL parse layer (matches at start of message text only, so real user messages that happen to discuss the syntax still render) -- Terminal-mode Claude sessions now honor the per-session model pick. The spawn command threads `sessions.model` through as `claude --model ` (alias like "sonnet"/"opus" or full id like "claude-sonnet-4-6"), matching the SDK-backed UI mode. Empty/unset model falls back to the user's Claude default -- Centered "Starting Claude Code…" spinner now overlays the terminal between the moment the PTY is requested and Claude printing its first frame, so the user gets feedback during the boot gap instead of seeing an empty xterm. Hides on the first non-empty `pty-output` event matching the terminal id (ignores empty keepalives and unrelated terminals) -- Toggling a fresh Claude session to terminal mode now spawns the TUI immediately instead of leaving the user in chat mode. Backend spawns plain `claude` (no `--resume` / no `--session-id`, since `--session-id` is actually a resume-an-existing-session flag despite the misleading help text) and watches `~/.claude/projects//` for the new `.jsonl` Claude writes when the user sends their first turn. On discovery the watcher persists the UUID as the session's `resume_session_id`, emits `session-resume-id` (so the rest of the app instantly sees the session as resumable), and starts the normal transcript tail. Subsequent reopens use the existing `--resume` path. `canUseTerminalView` no longer requires a resume id - any Claude session is eligible -- Move the UI / Terminal toggle into the Claude session tab as a tiny 2-segment icon control next to the cost (chat icon for UI, terminal icon for Terminal). Both segments are always visible so it reads as a toggle at a glance, with the active mode highlighted in accent. Tooltips are now jargon-free ("UI view" / "Terminal view"). The previous full-row pill above the chat is gone. Renders for any Claude session - including fresh ones with no resume id yet, so a brand-new task can opt in to terminal mode before the first message is sent (the actual swap to the PTY is gated by `canUseTerminalView` in TaskPanel and only happens once the resume id arrives, but the user's intent is captured immediately). On inactive session tabs the control collapses to zero width via a 200ms ease-out transition (max-width + opacity, with `-ml-1.5` cancelling the parent's gap so there's no phantom space) so changing the selected session slides the tab width smoothly instead of snapping. Respects `prefers-reduced-motion`. Clicking the toggle is a per-session override only - the global default is changed exclusively via Settings → General → Claude Code, so a one-off toggle on a single session doesn't quietly retrain what every future session opens as -- Switching between two terminal-mode Claude sessions in the same task now actually shows each session's own PTY instead of leaving the visible terminal stuck on whichever one mounted first. The view re-keys on `selectedSessionId` so it remounts on switch. The PTY itself stays alive across switches and across UI ↔ Terminal toggles - `claude_terminal_open` is idempotent per session so the second mount rejoins the existing handle, and ShellTerminal reattaches the existing xterm via the terminals registry. Real cleanup still happens at session close (`close_all_for_task`) and on PTY exit (`drop_if_stale`) -- Claude Code terminal sessions now capture pasted images: the JSONL transcript tail decodes `image` content blocks, writes the bytes to the blob store, and persists the user message as a `verun_user_message` line with attachment refs - so when you toggle back to UI view the chat shows the same image bubble you'd get from the in-app composer. Refcount tracking + GC keep storage bounded -- Fix Terminal toggle staying disabled after the first message: the resume_session_id was written to the DB but no Tauri event was emitted, so the live `Session` object's `resumeSessionId` stayed null until the session was reloaded. A new `session-resume-id` event now fires alongside every `SetResumeSessionId` write (Claude immediate, Codex turn-end, Codex thread/start, clear_session), and the frontend store updates the session in place -- Drag-and-drop now works in the Claude Code terminal view: drop one or more files anywhere over the terminal area and the absolute paths are typed into the PTY (single-quoted with POSIX escaping for spaces and quotes), so Claude's TUI picks them up the same way it would from a native terminal. Drops are forwarded directly via the `tauri://drag-drop` event with no bbox hit-test, so subsequent drops keep working after layout shifts (the previous version compared Tauri's physical-pixel position against `getBoundingClientRect`'s CSS pixels and silently dropped events after the first one). MessageInput is unmounted in terminal mode, so there's no competing drop target to disambiguate -- Cmd+V now pastes images into the Claude terminal view by letting xterm.js's native paste flow forward the bracketed-paste sequence to the PTY untouched (new `disableCmdVIntercept` prop on `ShellTerminal`). Claude Code's TUI polls NSPasteboard itself when it sees the paste sequence and renders the image as `[Image #1]`, matching its behavior in Apple Terminal. The previous approach saved the clipboard image to a temp file and typed the path - this turned out to be unnecessary because Claude Code reads the OS clipboard directly -- Fix garbled text in every PTY view (Claude terminal, shell terminals, bootstrap scaffold log): the reader was decoding each `read()` chunk with `String::from_utf8_lossy` independently, so any multi-byte codepoint that straddled a 4 KiB read boundary (😄 = 4 bytes, — = 3 bytes, … = 3 bytes - all common in Claude TUI output) got split into U+FFFD replacement characters. The `???` and `??` blobs and stray-letter artifacts users saw mid-frame were those replacement chars; later cell-position writes downstream of the corrupt chars then mis-rendered. Reader now keeps a partial-UTF-8 buffer across reads, emits only the longest valid prefix, and holds the trailing partial bytes for the next chunk. Genuinely invalid bytes still get replaced per the standard. Also swapped `WebglAddon` for `@xterm/addon-canvas` and dropped `customGlyphs` + `rescaleOverlappingGlyphs` while debugging - canvas + simpler config is a fine default; the UTF-8 fix is the actual root cause -- Changes pane: full overhaul. Three sections (Conflicts / Staged Changes / Changes), inline stage/unstage/discard/open-file actions, commit composer with smart-commit, amend, and Commit & Push, conflict-resolve dialog (ours / theirs / stage as-is), and corrected status taxonomy across Rust and TypeScript. Optimistic section-membership patches keep interactions snappy; the watcher reconciles. Preserves the commit draft on failed commit; binary staged/unstaged diffs short-circuit instead of rendering garbled UTF-8; Stage All and smart-commit fallback refuse when conflict markers are present; Amend is disabled while conflicts exist -- Auto-allow `git push --force-with-lease` and `--force-if-includes` in Normal trust (the safe variants used after rebase). Bare `--force` / `-f` and `--delete` still require approval -- Auto-allow Read/Glob/Grep/LSP under `~/.claude` (skill caches, plugin assets, settings) so Claude can load its own skills without prompting -- Task right-click menu now has Start App / Stop App, mirroring the toolbar's start-command toggle so you can run/kill a task's dev server without opening it. Disabled while the setup hook is running; if no start command is configured the entry becomes "Set Up Start Command..." and jumps straight to project settings -- Fix the git toolbar dropdown ("Commit & Push", "Merge PR", etc.) becoming unclickable while a session was generating a response. Each file/git event during streaming triggered `refreshTaskGit`, which wrote fresh `status`/`commits`/`branchStatus` objects via `produce`. The dropdown's action factories returned new `GitAction` objects on every reactive read, so `` saw new references and tore down + recreated every menu row, racing the user's mid-flight click. Action factories are now `createMemo` with stable references (label-based equals where the label can switch), and dynamic message bodies are computed inside the click handler so cached objects don't go stale -- Per-task keyboard shortcuts (Cmd+T new session, Cmd+P quick open, Cmd+Shift+F search, Cmd+E, Cmd+W, Cmd+Shift+T, Cmd+Alt+Arrow, Ctrl+`, Ctrl+Shift+`, Cmd+Shift+B, F5, Cmd+\, Ctrl+Tab, Ctrl+1-9) now share a single `useTaskShortcuts` hook used by both the main window and detached task windows, so shortcuts can't drift between shells. Fixes Cmd+T in detached task windows (#243), unifies behaviors that had silently diverged (e.g. Cmd+Shift+F now also seeds the search query from the text selection inside detached windows), and the new-session model picker now mounts in detached windows so ActionsPanel's "Fix in new session" button also surfaces there -- File-conflict dialog now mounts in detached task windows. Previously, saving an edit in a detached window where the file had changed on disk would silently abort without prompting the user to discard or overwrite, since the dialog was only rendered in the main window -- LSP diagnostics now populate in detached task windows. The Problems panel was empty there because `initProblemsListener` only ran in the main window; it now registers from the shared `initListeners()` path so both shells pick it up -- Sidebar no longer shows a stale unread badge for a task you watched in a detached window: `markTaskUnread` / `markTaskAttention` now skip when the task is already open in another window, since the user is seeing the output there in real time -- Projects that commit `.mcp.json` are no longer modified on task creation. Verun now writes its per-task `verun` MCP server entry to `/mcp-configs/.json` and passes it to Claude Code via `--mcp-config`, so the project's MCP config is never read or written. Migration: if a previous version added a `verun` entry to your `.mcp.json`, run `git checkout .mcp.json` once to clean it up -- ScheduleWakeup tool calls now actually wake up the session: when Claude schedules a follow-up with `delaySeconds` + `prompt`, Verun records the wakeup and a background scheduler resumes the session with the prompt once the delay elapses (#230) -- Wakeup-fired turns render as a "Wakeup fired - " chip styled like the existing tool blocks (collapsible chevron row with the prompt as the expanded body) instead of a green user bubble. Applies both to Verun-fired wakeups (UI mode) and TUI-fired wakeups picked up on terminal-to-chat toggle via the JSONL `isMeta` flag (#230) -- Fix: skipping an AskUserQuestion (Esc or the X button) now tells the agent "User dismissed this question without answering." instead of the generic "User denied this action." - the previous wording made the chat read like the user rejected the agent's request -- Fix: typing a number (1, 2, 3, ...) in the AskUserQuestion custom-answer field no longer selects that option instead of inserting the digit - the global option-shortcut handler now skips when an input/textarea is focused -- Fix: AskUserQuestion no longer gets silently auto-allowed under FullAuto trust, which was sending an empty answers map back to the agent and either freezing it or making it continue as if the user gave no answer. Now mirrors ExitPlanMode and always requires approval regardless of trust level -- Plan viewer's approve/request-changes dispatch extracted into a pure `decidePlanAction` helper with full unit coverage so the request-changes path can't silently fire approve (#216) -- "Ask sideways" pill no longer overlaps the last user bubble when the agent is idle and the user's message is the most recent one (e.g. after an interrupt) - the chat reserves extra bottom space in that case so the pill sits below the message -- Side question requests now keep running in the background if the panel is closed mid-flight. The "Ask sideways" pill shows a spinner ("Asking sideways...") while a request is in progress, then switches to an "Answer ready" state (accent ring + sparkle icon) when the answer arrives while the panel is closed. Click the pill to reopen and view the answer, or the inline X to dismiss the notification without opening -- Desktop notifications (task completed/failed, approval needed, question from task) now use the session name when set, falling back to the task name - gives precise context when a task has multiple named sessions (#247) +- Codex chat now auto-loads older history when the first page does not create a scrollbar, so long sessions no longer get stuck showing only the last few visible turns until you resize the window + +## 0.12.0 — 2026-06-08 + +### Better Multi-Window Workflow + +- Keyboard shortcuts now behave consistently in both the main window and detached task windows, including starting a new session, quick open, search, tab navigation, and fix-in-new-session flows +- Detached task windows now feel complete: save-conflict prompts appear correctly, Problems/LSP diagnostics show up there, and unread indicators stay in sync when you are already watching a task in another window + +### Smarter Follow-Ups And Notifications + +- Scheduled wakeups now reliably resume a session with the follow-up prompt when the timer expires, and those wakeups render clearly in chat instead of looking like a normal user message +- Side questions can keep running in the background if you close the panel, with clearer in-app states for "asking" and "answer ready" +- Desktop notifications now use the session name when available, so it is easier to tell which conversation needs attention + +### Updated Model Support + +- Claude session creation now surfaces Claude Opus 4.8 at the top of the model list +- Codex model selection has been refreshed to the current GPT-5.5-era coding models, and new Codex sessions now default to GPT-5.5 + +### Safer Project Setup + +- Creating a task no longer edits a committed `.mcp.json` in your repo; Verun keeps its per-task MCP wiring in app data instead ## 0.11.0 — 2026-05-12 diff --git a/VERSION b/VERSION index d9df1bbc..ac454c6a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.11.0 +0.12.0 diff --git a/package.json b/package.json index 6825a193..ca86dfc5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "verun", - "version": "0.11.0", + "version": "0.12.0", "description": "Parallel Claude Code Agent Orchestrator for macOS", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e5e4f2fa..54c7a8af 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6398,7 +6398,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "verun" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "dashmap", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6d8aeaa2..faea722b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "verun" -version = "0.11.0" +version = "0.12.0" description = "Parallel Claude Code Agent Orchestrator for macOS" authors = ["SoftwareSavants"] edition = "2021" diff --git a/src-tauri/src/agent/claude.rs b/src-tauri/src/agent/claude.rs index fcb14b4f..0db6b019 100644 --- a/src-tauri/src/agent/claude.rs +++ b/src-tauri/src/agent/claude.rs @@ -40,10 +40,15 @@ impl Agent for Claude { fn available_models(&self) -> Vec { use crate::agent::ModelOption; vec![ + ModelOption::new( + "claude-opus-4-8", + "Claude Opus 4.8", + "Latest and most capable", + ), ModelOption::new( "claude-opus-4-7", "Claude Opus 4.7", - "Latest and most capable", + "Previous flagship model", ) .with_min_version("2.1.111"), ModelOption::new( diff --git a/src-tauri/src/agent/codex.rs b/src-tauri/src/agent/codex.rs index eb61d773..7e21d2fd 100644 --- a/src-tauri/src/agent/codex.rs +++ b/src-tauri/src/agent/codex.rs @@ -76,31 +76,32 @@ impl Agent for Codex { fn available_models(&self) -> Vec { use crate::agent::ModelOption; vec![ - ModelOption::new("gpt-5.4", "GPT-5.4", "Latest frontier agentic coding model"), + ModelOption::new("gpt-5.5", "GPT-5.5", "Latest frontier coding model"), ModelOption::new( - "gpt-5.4-mini", - "GPT-5.4 Mini", - "Smaller frontier agentic coding model", + "gpt-5.5-pro", + "GPT-5.5 Pro", + "Higher-compute GPT-5.5 for complex coding work", ), + ModelOption::new("gpt-5.4", "GPT-5.4", "Frontier coding model"), ModelOption::new( - "gpt-5.3-codex", - "GPT-5.3 Codex", - "Codex-optimized agentic coding model", + "gpt-5.4-pro", + "GPT-5.4 Pro", + "Higher-compute GPT-5.4 for complex coding work", ), ModelOption::new( - "gpt-5.3-codex-spark", - "GPT-5.3 Codex Spark", - "Lightweight Codex model", + "gpt-5.4-mini", + "GPT-5.4 Mini", + "Smaller lower-latency frontier coding model", ), ModelOption::new( - "gpt-5.2-codex", - "GPT-5.2 Codex", - "Codex-optimized agentic coding model", + "gpt-5.4-nano", + "GPT-5.4 Nano", + "Lowest-cost GPT-5.4-class model", ), ModelOption::new( - "gpt-5.2", - "GPT-5.2", - "Optimized for professional work and long-running tasks", + "gpt-5.3-codex", + "GPT-5.3 Codex", + "Codex-optimized agentic coding model", ), ] } @@ -273,7 +274,7 @@ impl Agent for Codex { CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS }; let settings = json!({ - "model": params.model.unwrap_or("gpt-5.4"), + "model": params.model.unwrap_or("gpt-5.5"), "reasoning_effort": params.effort.unwrap_or("medium"), "developer_instructions": developer_instructions, }); diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs index 99e0425e..6384c5ea 100644 --- a/src-tauri/src/agent/mod.rs +++ b/src-tauri/src/agent/mod.rs @@ -772,6 +772,14 @@ mod tests { assert!(agent.uses_claude_jsonl()); } + #[test] + fn claude_available_models_include_opus_4_8_first() { + let models = Claude.available_models(); + assert_eq!(models[0].id, "claude-opus-4-8"); + assert_eq!(models[0].label, "Claude Opus 4.8"); + assert_eq!(models[1].id, "claude-opus-4-7"); + } + // ── Persistence + abort strategy + stream encoders ────────────────── #[test] @@ -1056,6 +1064,27 @@ mod tests { assert!(!agent.uses_claude_jsonl()); } + #[test] + fn codex_available_models_match_current_openai_coding_models() { + let ids: Vec = Codex + .available_models() + .into_iter() + .map(|m| m.id) + .collect(); + assert_eq!( + ids, + vec![ + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.3-codex", + ] + ); + } + #[test] fn other_agents_do_not_defer_resume_id() { assert!(!Claude.defers_resume_id_until_turn_end()); @@ -1312,6 +1341,26 @@ mod tests { assert_eq!(v["params"]["sandboxPolicy"]["type"], "readOnly"); } + #[test] + fn codex_encode_turn_start_defaults_collaboration_model_to_gpt_5_5() { + let bytes = Codex + .encode_rpc_turn_start( + 13, + &CodexRpcTurnStartParams { + thread_id: "t-xyz", + prompt: "ls", + image_urls: &[], + trust_level: crate::policy::TrustLevel::Normal, + model: None, + effort: None, + plan_mode: false, + }, + ) + .expect("encode"); + let v = parse_rpc_frame(&bytes); + assert_eq!(v["params"]["collaborationMode"]["settings"]["model"], "gpt-5.5"); + } + #[test] fn codex_encode_turn_start_appends_image_attachments() { let urls = vec!["data:image/png;base64,abc".to_string()]; diff --git a/src-tauri/src/fd_limit.rs b/src-tauri/src/fd_limit.rs index 5c7d2fd4..dd5773ff 100644 --- a/src-tauri/src/fd_limit.rs +++ b/src-tauri/src/fd_limit.rs @@ -8,6 +8,7 @@ //! out when a task is added) reliably hits EMFILE. Bumping the soft limit to //! something well above normal usage costs nothing and removes the ceiling. +#[cfg(unix)] use std::io; /// Raise the `RLIMIT_NOFILE` soft limit to `min(desired, hard_limit)` when it diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 1f155616..c5e4244f 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -2742,7 +2742,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let socket = std::path::Path::new("/tmp/v.sock"); let relay = std::path::Path::new("/usr/local/bin/verun-mcp-relay"); - write_mcp_config(dir.path(), "task-42", socket, relay).unwrap(); + write_verun_mcp_config(dir.path(), "task-42", socket, relay).unwrap(); let path = dir.path().join(".mcp.json"); let raw = std::fs::read_to_string(&path).unwrap(); @@ -2769,7 +2769,7 @@ mod tests { ) .unwrap(); - write_mcp_config( + write_verun_mcp_config( dir.path(), "t-1", std::path::Path::new("/sock"), @@ -2797,7 +2797,7 @@ mod tests { ) .unwrap(); - write_mcp_config( + write_verun_mcp_config( dir.path(), "new-task", std::path::Path::new("/new-sock"), @@ -2820,7 +2820,7 @@ mod tests { let path = dir.path().join(".mcp.json"); std::fs::write(&path, "not json at all{{").unwrap(); - write_mcp_config( + write_verun_mcp_config( dir.path(), "t-1", std::path::Path::new("/sock"), @@ -2838,7 +2838,7 @@ mod tests { let path = dir.path().join(".mcp.json"); std::fs::write(&path, r#"["not", "an", "object"]"#).unwrap(); - write_mcp_config( + write_verun_mcp_config( dir.path(), "t-1", std::path::Path::new("/sock"), @@ -2856,7 +2856,7 @@ mod tests { let path = dir.path().join(".mcp.json"); std::fs::write(&path, r#"{"mcpServers": "broken"}"#).unwrap(); - write_mcp_config( + write_verun_mcp_config( dir.path(), "t-1", std::path::Path::new("/sock"), @@ -2874,7 +2874,7 @@ mod tests { let info = dir.path().join(".git").join("info"); std::fs::create_dir_all(&info).unwrap(); - write_mcp_config( + write_verun_mcp_config( dir.path(), "t-1", std::path::Path::new("/sock"), @@ -2901,7 +2901,7 @@ mod tests { ) .unwrap(); - write_mcp_config( + write_verun_mcp_config( &worktree, "t-2", std::path::Path::new("/sock"), diff --git a/src-tauri/src/task.rs b/src-tauri/src/task.rs index 114b3958..8f641f65 100644 --- a/src-tauri/src/task.rs +++ b/src-tauri/src/task.rs @@ -1958,7 +1958,7 @@ async fn spawn_codex_app_server_session( } = params; let app_data = app.state::().0.clone(); - let mcp_config_path = mcp::verun_mcp_config_path(&app_data, &task_id); + let mcp_config_path = verun_mcp_config_path_for_task(&app_data, &task_id); let args_list = agent.build_session_args(&crate::agent::SessionArgs { resume_session_id: None, model: model.as_deref(), @@ -1969,7 +1969,7 @@ async fn spawn_codex_app_server_session( worktree_path: &worktree_path, repo_path: &repo_path, message: &message, - verun_mcp_config_path: Some(&mcp_config_path), + verun_mcp_config_path: mcp_config_path.as_deref(), }); eprintln!( @@ -2472,7 +2472,7 @@ pub async fn spawn_session_process( } = params; let resume_id = resume_session_id.as_deref().filter(|s| !s.is_empty()); let app_data = app.state::().0.clone(); - let mcp_config_path = mcp::verun_mcp_config_path(&app_data, &task_id); + let mcp_config_path = verun_mcp_config_path_for_task(&app_data, &task_id); let session_args = crate::agent::SessionArgs { resume_session_id: resume_id, model: model.as_deref(), @@ -2483,7 +2483,7 @@ pub async fn spawn_session_process( worktree_path: &worktree_path, repo_path: &repo_path, message: &message, - verun_mcp_config_path: Some(&mcp_config_path), + verun_mcp_config_path: mcp_config_path.as_deref(), }; let args_list = agent.build_session_args(&session_args); @@ -3055,6 +3055,21 @@ fn build_side_question_request(question: &str) -> serde_json::Value { }) } +fn verun_mcp_config_path_for_task( + app_data_dir: &std::path::Path, + task_id: &str, +) -> Option { + #[cfg(unix)] + { + Some(crate::mcp::verun_mcp_config_path(app_data_dir, task_id)) + } + #[cfg(not(unix))] + { + let _ = (app_data_dir, task_id); + None + } +} + /// Parse the CLI's `control_response` payload for a `side_question` request. /// The Anthropic SDK envelope is `{"response": {"subtype": "success", /// "request_id": ..., "response": {"response": "...", "synthetic": false}}}`, @@ -3825,6 +3840,22 @@ mod tests { assert!(args.iter().any(|a| a == "haiku")); } + #[test] + #[cfg(unix)] + fn verun_mcp_config_path_helper_matches_unix_mcp_path() { + let app_data = std::path::Path::new("/tmp/verun-app-data"); + let path = verun_mcp_config_path_for_task(app_data, "task-42"); + assert_eq!(path, Some(crate::mcp::verun_mcp_config_path(app_data, "task-42"))); + } + + #[test] + #[cfg(not(unix))] + fn verun_mcp_config_path_helper_is_none_off_unix() { + let app_data = std::path::Path::new(r"C:\verun-app-data"); + let path = verun_mcp_config_path_for_task(app_data, "task-42"); + assert_eq!(path, None); + } + #[test] fn side_question_request_has_correct_wire_shape() { let body = build_side_question_request("what was that var name?"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index dc3965b5..5dc5cbe5 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": "Verun", - "version": "0.11.0", + "version": "0.12.0", "identifier": "com.softwaresavants.verun", "build": { "beforeDevCommand": "node src-tauri/scripts/copy-relay-sidecar.mjs && pnpm dev", diff --git a/src/components/ChatView.pagination.test.tsx b/src/components/ChatView.pagination.test.tsx new file mode 100644 index 00000000..62830308 --- /dev/null +++ b/src/components/ChatView.pagination.test.tsx @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { cleanup, render } from '@solidjs/testing-library' +import type { OutputItem } from '../types' + +vi.mock('../lib/markdown', () => ({ + renderMarkdown: (text: string) => text, + handleMarkdownLinkClick: vi.fn(), + getWorktreePath: vi.fn(() => '/tmp/worktree'), +})) + +vi.mock('../lib/mentions', () => ({ + parseMentions: (text: string) => [{ type: 'text', value: text }], +})) + +vi.mock('../lib/format', () => ({ + formatCost: vi.fn(() => '$0.00'), + formatTokens: vi.fn((n: number) => String(n)), +})) + +vi.mock('../lib/ipc', () => ({})) + +vi.mock('../store/sideQuestion', () => ({ + dismissSideQuestionUnread: vi.fn(), + openSideQuestion: vi.fn(), + sideQuestionPanel: vi.fn(() => null), + sideQuestionState: vi.fn(() => ({ loading: false, unread: false })), +})) + +vi.mock('../store/ui', () => ({ + addToast: vi.fn(), + setSelectedTaskId: vi.fn(), + setSelectedSessionIdForTask: vi.fn(), +})) + +vi.mock('../store/tasks', () => ({ + setTasks: vi.fn(), +})) + +vi.mock('../store/editorView', () => ({ + setMainView: vi.fn(), +})) + +vi.mock('../store/sessionContext', () => ({ + planModeForSession: vi.fn(() => false), + thinkingModeForSession: vi.fn(() => false), + fastModeForSession: vi.fn(() => false), +})) + +const loadOlderOutputLines = vi.fn(async () => 0) +let hasMore = false + +vi.mock('../store/sessions', () => ({ + setSessions: vi.fn(), + loadOutputLines: vi.fn(), + loadOlderOutputLines: (...args: unknown[]) => loadOlderOutputLines(...args), + hasMoreOutputLines: vi.fn(() => hasMore), + sendMessage: vi.fn(), + createSession: vi.fn(), +})) + +vi.mock('./FileMentionBadge', () => ({ + FileMentionBadge: (props: { filePath: string }) => {props.filePath}, +})) + +vi.mock('./ImageViewer', () => ({ + ImageViewer: () => null, +})) + +vi.mock('./BlobImage', () => ({ + BlobImage: () => null, +})) + +vi.mock('./Popover', () => ({ + Popover: (props: { children: unknown }) => props.children, +})) + +import { ChatView } from './ChatView' + +function item(text: string, timestamp: number): OutputItem { + return { kind: 'userMessage', text, timestamp } as OutputItem +} + +describe('ChatView older-history pagination', () => { + beforeEach(() => { + cleanup() + vi.useFakeTimers() + vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => setTimeout(() => fn(0), 0)) + hasMore = false + loadOlderOutputLines.mockReset() + }) + + test('auto-loads older pages when the initial render does not overflow the viewport', async () => { + hasMore = true + loadOlderOutputLines.mockImplementation(async () => { + hasMore = false + return 5 + }) + + const { container } = render(() => ( +
+ +
+ )) + + const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement + expect(scroller).toBeTruthy() + + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 900 }) + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, get: () => 300 }) + Object.defineProperty(scroller, 'scrollTop', { configurable: true, writable: true, value: 0 }) + + await vi.runAllTimersAsync() + + expect(loadOlderOutputLines).toHaveBeenCalledWith('s-codex') + expect(loadOlderOutputLines).toHaveBeenCalledTimes(1) + }) + + test('keeps auto-loading older pages until the viewport becomes scrollable', async () => { + hasMore = true + let callCount = 0 + loadOlderOutputLines.mockImplementation(async () => { + callCount += 1 + if (callCount >= 2) hasMore = false + return 5 + }) + + const { container } = render(() => ( +
+ +
+ )) + + const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement + expect(scroller).toBeTruthy() + + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 900 }) + Object.defineProperty(scroller, 'scrollTop', { configurable: true, writable: true, value: 0 }) + Object.defineProperty(scroller, 'scrollHeight', { + configurable: true, + get: () => (callCount >= 2 ? 1400 : 300), + }) + + await vi.runAllTimersAsync() + + expect(loadOlderOutputLines).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 71010bef..a74d08ec 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1096,12 +1096,21 @@ export const ChatView: Component = (props) => { if (!restore.autoScroll) { requestAnimationFrame(() => { if (containerRef) containerRef.scrollTop = restore.scrollTop + maybeBackfillViewport() }) } else { scheduleAutoScroll() + requestAnimationFrame(() => { + if (!mounted) return + maybeBackfillViewport() + }) } } else { scheduleAutoScroll() + requestAnimationFrame(() => { + if (!mounted) return + maybeBackfillViewport() + }) } } } @@ -1138,6 +1147,16 @@ export const ChatView: Component = (props) => { // switches sessions doesn't end up writing scrollTop to the detached div. let mounted = true onCleanup(() => { mounted = false }) + function viewportNeedsOlderPage() { + const sid = props.sessionId + return !!sid + && !!containerRef + && hasMoreOutputLines(sid) + && containerRef.scrollHeight <= containerRef.clientHeight + 1 + } + function maybeBackfillViewport() { + if (viewportNeedsOlderPage()) void maybeLoadOlder() + } const maybeLoadOlder = async () => { if (olderLoadInFlight) return const sid = props.sessionId @@ -1156,8 +1175,14 @@ export const ChatView: Component = (props) => { if (!mounted || !containerRef) return const delta = containerRef.scrollHeight - oldScrollHeight if (delta > 0) containerRef.scrollTop = oldScrollTop + delta + maybeBackfillViewport() }) }) + } else { + requestAnimationFrame(() => { + if (!mounted) return + maybeBackfillViewport() + }) } } finally { olderLoadInFlight = false diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index 07430a6f..e0cced44 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -1065,7 +1065,7 @@ const AppearancePreview: Component = () => { {/* Model chip — mirrors MessageInput's layout/hierarchy */} - Claude Opus 4.7 + Claude Opus 4.8 {/* Plan toggle, shown active so the accent color is visible */}