From 1368d84baccb50d17a26f102e7b3ea16e93dfcdd Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Mon, 22 Jun 2026 12:49:48 +0530 Subject: [PATCH 01/82] fix(onboarding): filter non-chat models from provider list using models.dev --- apps/x/packages/core/src/models/models-dev.ts | 19 +++++++++++++++++++ apps/x/packages/core/src/models/models.ts | 12 +++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/x/packages/core/src/models/models-dev.ts b/apps/x/packages/core/src/models/models-dev.ts index 4a86c1b2..50e91411 100644 --- a/apps/x/packages/core/src/models/models-dev.ts +++ b/apps/x/packages/core/src/models/models-dev.ts @@ -224,3 +224,22 @@ export async function listOnboardingModels(): Promise<{ providers: ProviderSumma return { providers, lastUpdated: fetchedAt }; } + +export async function getChatModelIds( + flavor: "openai" | "anthropic" | "google", +): Promise> { + try { + const { data } = await getModelsDevData(); + const provider = pickProvider(data, flavor); + if (!provider) return new Set(); + const ids = new Set(); + for (const [id, model] of Object.entries(provider.models)) { + if (isStableModel(model) && supportsToolCall(model)) { + ids.add(model.id ?? id); + } + } + return ids; + } catch { + return new Set(); + } +} diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index 1939f7b7..c50393c6 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -10,6 +10,7 @@ import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js"; import z from "zod"; import { getGatewayProvider } from "./gateway.js"; import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js"; +import { getChatModelIds } from "./models-dev.js"; import { withUseCase } from "../analytics/use_case.js"; export const Provider = LlmProvider; @@ -158,7 +159,16 @@ export async function listModelsForProvider( // OpenAI-shaped: { data: [{ id: "..." }] } ids = (data.data ?? []).map((m: { id: string }) => m.id); } - return ids.filter((id: string) => typeof id === "string" && id.length > 0); + const cleaned = ids.filter((id: string) => typeof id === "string" && id.length > 0); + if (flavor === "openai" || flavor === "anthropic" || flavor === "google") { + const chatIds = await getChatModelIds(flavor); + // Only filter when models.dev returned data; if it's empty (offline/no + // cache/unknown provider) keep the full list rather than showing none. + if (chatIds.size > 0) { + return cleaned.filter((id) => chatIds.has(id)); + } + } + return cleaned; } finally { clearTimeout(timeout); } From c0b38d46d3e57aa523983f1dc79f0af290427cec Mon Sep 17 00:00:00 2001 From: Harshvardhan Vatsa <76729417+hrsvrn@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:25:19 +0530 Subject: [PATCH 02/82] Fix/GitHub workflow (#638) * fixed workflow * fix(forge): hoist staged ACP node_modules to stay under Windows MAX_PATH The Squirrel/nuget Windows maker failed with 'path too long': stageAcpAdapters rebuilt the ACP dependency closure by always nesting each dep under its parent, producing 5-level chains (codex-acp -> open -> wsl-utils -> is-wsl -> is-inside-container -> is-docker) whose paths hit ~260 chars on the CI runner and blew past Windows' MAX_PATH (nuget.exe ignores long-path settings). Hoist every package to the top-level node_modules npm-style, nesting only on a genuine version conflict. Deepest staged path drops 177 -> 114 chars (worst-case CI absolute ~197, well under 260). Add verifyAcpStaging() which asserts every dependency edge resolves, in the staged tree, to the same version as the source pnpm tree, failing the build loudly instead of shipping a broken installer. --- .github/workflows/electron-build.yml | 15 ++- apps/x/apps/main/bundle.mjs | 23 ++++ apps/x/apps/main/forge.config.cjs | 188 ++++++++++++++++++++------- 3 files changed, 181 insertions(+), 45 deletions(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 787e28e6..e68ce4f4 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -163,12 +163,25 @@ jobs: run: pnpm install --frozen-lockfile working-directory: apps/x + - name: Build node-pty native binary for Linux + working-directory: apps/x + run: | + # node-pty ships prebuilt binaries only for darwin/win32; compile the + # linux-x64 binary so bundle.mjs can stage it into the package. Without + # this the Linux app crashes on launch (missing prebuilds/linux-x64/pty.node). + PTY="node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty" + cd "$PTY" + npx node-gyp rebuild + mkdir -p prebuilds/linux-x64 + cp build/Release/pty.node prebuilds/linux-x64/ + - name: Build electron app env: VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx electron-forge publish --arch=x64,arm64 --platform=linux + ROWBOAT_SKIP_PACMAN: '1' # Arch Linux package is built locally only, never in CI + run: npx electron-forge publish --arch=x64 --platform=linux working-directory: apps/x/apps/main - name: Upload workflow artifacts diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index fa62d0db..548e037c 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -11,6 +11,7 @@ import * as esbuild from 'esbuild'; import { readFile } from 'node:fs/promises'; +import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -66,6 +67,28 @@ for (const dir of fs.readdirSync(prebuildsDir)) { const helper = path.join(prebuildsDir, dir, 'spawn-helper'); if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755); } + +// Self-heal: node-pty ships prebuilt binaries only for darwin/win32, so on any +// host whose prebuild is absent (notably Linux) the staged package has no loadable +// pty.node and the app crashes on launch. Compile the native module for the host +// platform+arch if needed and stage it under prebuilds/-/, where +// node-pty's loader looks first. Keeps dev and CI working without a manual node-gyp +// step (the CI workflow's explicit build is the fast path; this is the safety net). +const hostTriple = `${process.platform}-${process.arch}`; +const stagedBinary = path.join(prebuildsDir, hostTriple, 'pty.node'); +if (!fs.existsSync(stagedBinary)) { + const builtBinary = path.join(ptySrc, 'build', 'Release', 'pty.node'); + if (!fs.existsSync(builtBinary)) { + console.log(`node-pty: no prebuilt binary for ${hostTriple}; compiling with node-gyp…`); + execSync('npx node-gyp rebuild', { cwd: ptySrc, stdio: 'inherit' }); + } + if (!fs.existsSync(builtBinary)) { + throw new Error(`node-pty: failed to produce a native binary for ${hostTriple}`); + } + fs.mkdirSync(path.dirname(stagedBinary), { recursive: true }); + fs.copyFileSync(builtBinary, stagedBinary); + console.log(`✅ node-pty: staged ${hostTriple}/pty.node`); +} console.log('✅ node-pty staged in .package/node_modules'); // Bundle the vendored agent-slack CLI into a single self-contained script next diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 5e33bb49..3b967655 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -5,6 +5,12 @@ const path = require('path'); const pkg = require('./package.json'); +// The Arch Linux (pacman) package is meant only for local builds on an Arch host +// with makepkg. It already self-skips elsewhere (maker-pacman checks for makepkg), +// but CI sets ROWBOAT_SKIP_PACMAN=1 to disable it explicitly — GitHub runners are +// Ubuntu and shouldn't attempt to ship an Arch package. +const SKIP_PACMAN = process.env.ROWBOAT_SKIP_PACMAN === '1'; + // Stage the ACP coding-adapters (@agentclientprotocol/*-acp) and their full // production dependency closure into the packaged app. // @@ -14,53 +20,88 @@ const pkg = require('./package.json'); // strips the workspace node_modules (see `ignore` below). Without this, packaged // builds throw `Cannot find module '@agentclientprotocol/...'`. // -// Why we reconstruct a nested tree instead of copying node_modules: pnpm's store is a +// Why we reconstruct the tree instead of copying node_modules: pnpm's store is a // symlink farm that legitimately holds multiple versions of the same package (e.g. // @agentclientprotocol/sdk 0.21 for claude vs 0.22 for codex). We rebuild an npm-style -// nested node_modules — dereferencing symlinks and nesting on version conflict — which -// resolves correctly regardless of pnpm layout. +// node_modules — dereferencing symlinks — that resolves correctly regardless of pnpm +// layout. We HOIST every package to the top-level node_modules and only nest a package +// under its requirer on a genuine version conflict. Hoisting (vs. always nesting) keeps +// the tree shallow: without it, transitive chains like codex-acp → open → wsl-utils → +// is-wsl → is-inside-container → is-docker nest 5+ deep and produce ~260-char paths that +// break the Windows Squirrel/nuget maker's MAX_PATH limit. Node resolution stays correct +// because the top-level node_modules is an ancestor of every staged file, so a hoisted +// package resolves for all requirers and a conflicting version shadows it via nesting. +// verifyAcpStaging() below asserts this held for every dependency edge. // // What we DON'T bundle: the agents' native engines (claude / codex, ~200 MB each, shipped // as platform-specific packages). Those are PROVISIONED on demand into // ~/.rowboat/engines/// and the adapters are pointed at them via // CLAUDE_CODE_EXECUTABLE / CODEX_PATH (see packages/core/src/code-mode/acp/). Skipping // them keeps each OS installer ~400 MB smaller while code mode stays fully functional. +// Shared by stageAcpAdapters and verifyAcpStaging so staging and verification use +// identical resolution semantics. +const ACP_ADAPTERS = [ + '@agentclientprotocol/claude-agent-acp', + '@agentclientprotocol/codex-acp', +]; + +// The native engines, shipped as platform packages. Provisioned on demand +// (see header comment), so they're excluded from staging. +const isAcpNativeEngine = (key) => + /^@anthropic-ai\/claude-agent-sdk-(win32|darwin|linux)/.test(key) || // native claude + /^@openai\/codex-(win32|darwin|linux)/.test(key); // native codex + +// Resolve a dependency's real directory by walking node_modules the way Node does, +// looking for the package DIRECTORY. We deliberately do NOT use +// require.resolve(`${key}/package.json`): that throws for packages whose `exports` +// map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk), which would +// silently drop them and their subtrees. realpathSync dereferences pnpm's symlinks. +// Returns null for deps not installed for this OS (platform-optional binaries). +const acpRealDirOf = (key, fromDir) => { + const fs = require('fs'); + let dir = fromDir; + for (;;) { + const cand = path.join(dir, 'node_modules', ...key.split('/')); + if (fs.existsSync(path.join(cand, 'package.json'))) return fs.realpathSync(cand); + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +}; + function stageAcpAdapters(mainDir, destNodeModules) { const fs = require('fs'); - const ADAPTERS = [ - '@agentclientprotocol/claude-agent-acp', - '@agentclientprotocol/codex-acp', - ]; - - // The native engines, shipped as platform packages. Provisioned on demand instead - // (see comment above), so they're excluded from staging. - const isNativeEngine = (key) => - /^@anthropic-ai\/claude-agent-sdk-(win32|darwin|linux)/.test(key) || // native claude - /^@openai\/codex-(win32|darwin|linux)/.test(key); // native codex - - // Resolve a dependency's real directory by walking node_modules the way Node does, - // looking for the package DIRECTORY. We deliberately do NOT use - // require.resolve(`${key}/package.json`): that throws for packages whose `exports` - // map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk), which would - // silently drop them and their subtrees. realpathSync dereferences pnpm's symlinks. - // Returns null for deps not installed for this OS (platform-optional binaries). - const realDirOf = (key, fromDir) => { - let dir = fromDir; - for (;;) { - const cand = path.join(dir, 'node_modules', ...key.split('/')); - if (fs.existsSync(path.join(cand, 'package.json'))) return fs.realpathSync(cand); - const parent = path.dirname(dir); - if (parent === dir) return null; - dir = parent; - } - }; let copied = 0; const skippedEngines = new Set(); - const install = (srcDir, key, destNM, chain) => { - const destDir = path.join(destNM, ...key.split('/')); - if (fs.existsSync(destDir)) return; // already placed at this exact location + // srcRealDir -> the staged directory whose content represents it. Lets + // verifyAcpStaging map every source package to where it landed. + const placements = new Map(); + // package key -> version placed at the TOP-LEVEL node_modules. We hoist every + // package to the top level and only nest a package under its requirer when a + // DIFFERENT version is already hoisted there. See the header comment for why + // (a shallow tree stays under Windows' MAX_PATH). + const rootHoisted = new Map(); + const install = (srcDir, key, parentNM, chain) => { if (chain.has(srcDir)) return; // dependency cycle — resolves to ancestor copy + const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8')); + const version = pj.version; + const hoisted = rootHoisted.get(key); + let destNM; + if (hoisted === undefined) { + destNM = destNodeModules; // first sighting → hoist to the top level + rootHoisted.set(key, version); + } else if (hoisted === version) { + // identical version already hoisted at root → reuse it (its subtree is + // already staged); just record where this srcDir resolves to. + placements.set(srcDir, path.join(destNodeModules, ...key.split('/'))); + return; + } else { + destNM = parentNM; // genuine version conflict → nest under requirer + } + const destDir = path.join(destNM, ...key.split('/')); + placements.set(srcDir, destDir); + if (fs.existsSync(destDir)) return; // already placed at this exact location fs.mkdirSync(path.dirname(destDir), { recursive: true }); fs.cpSync(srcDir, destDir, { recursive: true, @@ -68,18 +109,17 @@ function stageAcpAdapters(mainDir, destNodeModules) { filter: (s) => path.basename(s) !== 'node_modules', // deps handled by recursion }); copied++; - const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8')); const deps = { ...pj.dependencies, ...pj.optionalDependencies }; const nextChain = new Set(chain).add(srcDir); for (const depKey of Object.keys(deps)) { - if (isNativeEngine(depKey)) { skippedEngines.add(depKey); continue; } - const depDir = realDirOf(depKey, srcDir); + if (isAcpNativeEngine(depKey)) { skippedEngines.add(depKey); continue; } + const depDir = acpRealDirOf(depKey, srcDir); if (depDir) install(depDir, depKey, path.join(destDir, 'node_modules'), nextChain); } }; - for (const key of ADAPTERS) { - const srcDir = realDirOf(key, mainDir); + for (const key of ACP_ADAPTERS) { + const srcDir = acpRealDirOf(key, mainDir); if (!srcDir) { throw new Error(`ACP adapter '${key}' is not installed in ${mainDir} — run pnpm install`); } @@ -88,7 +128,64 @@ function stageAcpAdapters(mainDir, destNodeModules) { if (skippedEngines.size) { console.log(` (skipped native engines — provisioned on demand: ${[...skippedEngines].join(', ')})`); } - return copied; + return { copied, placements }; +} + +// Fail the build LOUDLY if hoisting misplaced anything. Re-walk the source dependency +// closure and assert that every (package → dependency) edge resolves, in the STAGED +// tree, to the SAME version it resolves to in the SOURCE pnpm tree. This converts a +// silent runtime "Cannot find module" (or a wrong-version resolution from a botched +// hoist) into an immediate build failure. Expectations are derived from the source +// tree — nothing is hardcoded — so it keeps working as the dependency set changes. +function verifyAcpStaging(mainDir, placements) { + const fs = require('fs'); + const versionAt = (dir) => + JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version; + // Resolve `key`'s staged version as seen from `fromStagedDir`, via Node's own + // upward node_modules walk. Reads package.json directly (not require.resolve, whose + // `${key}/package.json` subpath some exports maps block). + const stagedVersionOf = (key, fromStagedDir) => { + let dir = fromStagedDir; + for (;;) { + const cand = path.join(dir, 'node_modules', ...key.split('/')); + if (fs.existsSync(path.join(cand, 'package.json'))) return versionAt(cand); + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + }; + const errors = []; + const visited = new Set(); + const walk = (srcDir) => { + if (visited.has(srcDir)) return; + visited.add(srcDir); + const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8')); + const stagedDir = placements.get(srcDir); + if (!stagedDir) { errors.push(`not staged: ${pj.name}@${pj.version}`); return; } + const deps = { ...pj.dependencies, ...pj.optionalDependencies }; + for (const depKey of Object.keys(deps)) { + if (isAcpNativeEngine(depKey)) continue; + const depSrc = acpRealDirOf(depKey, srcDir); + if (!depSrc) continue; // platform-optional / not installed for this OS + const want = versionAt(depSrc); + const got = stagedVersionOf(depKey, stagedDir); + if (got === null) { + errors.push(`${pj.name} → ${depKey}: unresolved in staged tree (expected ${want})`); + } else if (got !== want) { + errors.push(`${pj.name} → ${depKey}: staged resolves ${got}, source resolves ${want}`); + } + walk(depSrc); + } + }; + for (const key of ACP_ADAPTERS) { + const srcDir = acpRealDirOf(key, mainDir); + if (srcDir) walk(srcDir); + } + if (errors.length) { + throw new Error( + `ACP staging verification failed — the staged tree resolves differently than source:\n - ${errors.join('\n - ')}` + ); + } } module.exports = { @@ -178,7 +275,8 @@ module.exports = { } } }, - { + // Arch Linux package — local-only; disabled in CI via ROWBOAT_SKIP_PACMAN. + ...(SKIP_PACMAN ? [] : [{ name: require.resolve('./makers/maker-pacman.cjs'), platforms: ['linux'], config: { @@ -192,7 +290,7 @@ module.exports = { icon: path.join(__dirname, 'icons/icon.png'), mimeType: ['x-scheme-handler/rowboat'], } - }, + }]), { name: '@electron-forge/maker-zip', platform: ["darwin", "win32", "linux"], @@ -291,10 +389,12 @@ module.exports = { // must be copied in explicitly. See stageAcpAdapters() above for the why. console.log('Staging ACP adapters...'); const acpDest = path.join(packageDir, 'acp', 'node_modules'); - const staged = stageAcpAdapters(__dirname, acpDest); - console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules`); + const { copied: staged, placements } = stageAcpAdapters(__dirname, acpDest); + // Assert the hoisted tree resolves identically to source before shipping it. + verifyAcpStaging(__dirname, placements); + console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules (resolution verified)`); console.log('✅ All assets staged in .package/'); }, } -}; \ No newline at end of file +}; From 1bfda94f48fcd26aa280944a5ad608a15cd1ac3c Mon Sep 17 00:00:00 2001 From: PRAKHAR PANDEY Date: Wed, 24 Jun 2026 01:36:06 +0530 Subject: [PATCH 03/82] fix: suppress notification spam on app re-open (#623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: suppress notification spam on app re-open - Add background_task notification category (default ON) so users can toggle off background-task pings via Settings → Notifications - Route notify-user builtin through notifyIfEnabled gate so the category toggle takes effect - Add 60s startup grace period: background-task notifications fired within 60s of launch are suppressed, killing the reopen flood where all queued agents complete at once - Suppress new_email notifications for emails older than 5 min so Gmail's startup backlog replay doesn't surface day-old mail Fixes both issues reported by Ramnique. Co-Authored-By: Claude Opus 4.8 * fix: skip automatic chat_completion ping for background task agents Background task runs were triggering "Response ready / Your agent finished responding" on every completion. Skip the automatic chat_completion notification when finalState.runUseCase === 'background_task_agent' — background tasks notify explicitly via notify-user when they have something worth surfacing. Co-Authored-By: Claude Opus 4.8 * fix: correct notification deeplinks and skip internal agent pings - runtime.ts: also skip chat_completion ping for knowledge_sync useCase (agent_notes_agent was opening notes view on click) - sync_gmail.ts: new_email notification now links to specific email thread (rowboat://open?type=email&threadId=...) - App.tsx: add email case to parseDeepLink, parse threadId param, wire threadId through applyViewState, update viewStatesEqual Co-Authored-By: Claude Opus 4.8 * fix: distinguish background-agent notifications from auto knowledge-sync Per team clarification, two background types are handled differently: - knowledge_sync (auto knowledge-graph generation): never notifies; skips the generic chat_completion ping entirely. - background_task_agent (user-configured agents): notifies via its own notify-user path, gated behind the toggleable "Background agents" category, deep-linking to the background-tasks page. - runtime.ts: skip the generic chat_completion completion ping for both knowledge_sync and background_task_agent (the latter notifies via notify-user, so the generic ping would duplicate it). - builtin-tools.ts: notify-user branches on getCurrentUseCase() — background agents route through notifyIfEnabled('background_task') with a bg-tasks deeplink default; chat agents notify directly. - App.tsx: add the bg-tasks deeplink target (ViewState, parseDeepLink, applyViewState, currentViewState). - settings-dialog.tsx: rename the category label to "Background agents". - runner.ts: wrap the background-task run in withUseCase so tools see the correct use-case context. * fix: route coding-session notifications through notifyIfEnabled Code-mode status-tracker was calling notificationService.notify() directly, bypassing the user's notification-category toggles. Routed both calls through notifyIfEnabled() with correct categories: - needs-you state → agent_permission - idle after 30s → chat_completion Removed now-redundant container/INotificationService plumbing. * fix: fall back to persisted run useCase when ALS context is missing in notify-user AsyncLocalStorage does not propagate across the background-task agent's async generator — getCurrentUseCase() returns undefined inside notify-user, causing the background_task_agent branch to be skipped and the Background agents toggle to be ignored. Fix: load persisted useCase from run record via fetchRun(ctx.runId) when ALS is falsy. Lazy dynamic import() avoids the known module-init cycle. Background-agent notifications now correctly respect the toggle and only fire when the app is in the background, with deep link to the bg-tasks page. --------- Co-authored-by: Claude Opus 4.8 --- apps/x/apps/main/src/main.ts | 7 +++- .../electron-notification-service.ts | 19 +++++++++- apps/x/apps/renderer/src/App.tsx | 34 ++++++++++++++++-- .../src/components/settings-dialog.tsx | 7 +++- apps/x/packages/core/src/agents/runtime.ts | 14 ++++++++ .../core/src/application/lib/builtin-tools.ts | 36 +++++++++++++++++-- .../application/notification/service.test.ts | 32 +++++++++++++++++ .../src/application/notification/service.ts | 31 ++++++++++++++++ .../core/src/background-tasks/runner.ts | 14 +++++++- .../src/code-mode/sessions/status-tracker.ts | 20 +++++------ .../core/src/knowledge/sync_gmail.test.ts | 30 ++++++++++++++++ .../packages/core/src/knowledge/sync_gmail.ts | 24 +++++++++++-- .../shared/src/notification-settings.ts | 4 +++ 13 files changed, 250 insertions(+), 22 deletions(-) create mode 100644 apps/x/packages/core/src/application/notification/service.test.ts diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 0c7bc9b9..f2843359 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -56,6 +56,11 @@ import { } from "./deeplink.js"; import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; +// Captured as early as possible so it reflects actual process start. Used to +// gate grace-eligible notifications (e.g. the burst of background-task +// completions a reopen replays) — see ElectronNotificationService. +const APP_LAUNCHED_AT = Date.now(); + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -330,7 +335,7 @@ app.whenReady().then(async () => { }); registerBrowserControlService(new ElectronBrowserControlService()); - registerNotificationService(new ElectronNotificationService()); + registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT)); setupIpcHandlers(); setupBrowserEventForwarding(); diff --git a/apps/x/apps/main/src/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts index d86a4898..22660343 100644 --- a/apps/x/apps/main/src/notification/electron-notification-service.ts +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -1,5 +1,6 @@ import { BrowserWindow, Notification, shell } from "electron"; import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js"; +import { shouldSuppressDuringStartupGrace } from "@x/core/dist/application/notification/service.js"; import { dispatchUrl } from "../deeplink.js"; const HTTP_URL = /^https?:\/\//i; @@ -11,11 +12,27 @@ export class ElectronNotificationService implements INotificationService { // gets dropped and macOS clicks just focus the app silently. private active = new Set(); + // Timestamp the app launched, used to gate grace-eligible notifications (see + // NotifyInput.suppressDuringStartupGrace). Captured by the caller in main.ts + // so it reflects process start rather than whenever the first notify fires. + private readonly launchedAt: number; + + constructor(launchedAt: number = Date.now()) { + this.launchedAt = launchedAt; + } + isSupported(): boolean { return Notification.isSupported(); } - notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground }: NotifyInput): void { + notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground, suppressDuringStartupGrace }: NotifyInput): void { + // Startup grace: a reopen replays every background task that completed + // while the app was closed, so grace-eligible notifications fired in the + // first moments after launch are dropped to avoid a notification flood. + if (shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace }, this.launchedAt)) { + return; + } + // Ambient notifications are suppressed while the app is in the // foreground — the user is already looking at it. A window counts as // foreground only if it's actually focused (minimized / other-space diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 9d333581..ea575a26 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -627,12 +627,13 @@ type ViewState = | { type: 'suggested-topics' } | { type: 'meetings' } | { type: 'live-notes' } - | { type: 'email' } + | { type: 'email'; threadId?: string } | { type: 'workspace'; path?: string } | { type: 'knowledge-view'; folderPath?: string; mode?: KnowledgeViewMode } | { type: 'chat-history' } | { type: 'home' } | { type: 'code' } + | { type: 'bg-tasks' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -641,6 +642,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type === 'task' && b.type === 'task') return a.name === b.name if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '') if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') && (a.mode ?? '') === (b.mode ?? '') + if (a.type === 'email' && b.type === 'email') return (a.threadId ?? '') === (b.threadId ?? '') return true // both graph } @@ -648,7 +650,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is * malformed or names an unknown target. * - * Shape: rowboat://open?type=&... + * Shape: rowboat://open?type=&... * file: ?type=file&path=knowledge/foo.md * chat: ?type=chat&runId=abc123 (runId optional) * graph: ?type=graph @@ -656,6 +658,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { * suggested-topics: ?type=suggested-topics * meetings: ?type=meetings * live-notes: ?type=live-notes + * email: ?type=email */ function parseDeepLink(input: string): ViewState | null { const SCHEME = 'rowboat://' @@ -684,6 +687,10 @@ function parseDeepLink(input: string): ViewState | null { return { type: 'meetings' } case 'live-notes': return { type: 'live-notes' } + case 'email': { + const threadId = params.get('threadId') + return { type: 'email', threadId: threadId || undefined } + } case 'workspace': { const path = params.get('path') return { type: 'workspace', path: path ?? undefined } @@ -703,6 +710,8 @@ function parseDeepLink(input: string): ViewState | null { return { type: 'home' } case 'code': return { type: 'code' } + case 'bg-tasks': + return { type: 'bg-tasks' } default: return null } @@ -3758,6 +3767,7 @@ function App() { if (isChatHistoryOpen) return { type: 'chat-history' } if (isHomeOpen) return { type: 'home' } if (isCodeOpen) return { type: 'code' } + if (isBgTasksOpen) return { type: 'bg-tasks' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } @@ -4089,6 +4099,12 @@ function App() { setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) setIsHomeOpen(false) + // Deep links (e.g. a new-email notification) carry the thread to open; + // bump the version so EmailView re-selects it even if email is already open. + if (view.threadId) { + setEmailInitialThreadId(view.threadId) + setEmailThreadIdVersion((v) => v + 1) + } ensureEmailFileTab() return case 'workspace': @@ -4176,6 +4192,18 @@ function App() { setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) ensureCodeFileTab() return + case 'bg-tasks': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + setIsBgTasksOpen(true) + ensureBgTasksFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -4204,7 +4232,7 @@ function App() { } return } - }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, ensureCodeFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, ensureCodeFileTab, ensureBgTasksFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 1319abb9..52873761 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2075,7 +2075,7 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) { // --- Notification Settings --- -type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" +type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task" const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [ { @@ -2093,6 +2093,11 @@ const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; de label: "Permission requests", description: "When an agent needs your approval to run a tool. Always shown, even when the app is focused.", }, + { + key: "background_task", + label: "Background agents", + description: "When a background agent you've set up has something to surface. Click to open it on the background tasks page.", + }, ] function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) { diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 0f2c22d5..da756b2c 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -448,6 +448,20 @@ export class AgentRuntime implements IAgentRuntime { finalState.ingest(event); } if (finalState.getPendingPermissions().length === 0) { + // This generic completion ping is only for real user + // chats (copilot_chat). Skip it for: + // - knowledge_sync: an internal, auto-running agent + // (knowledge-graph generation) that never notifies at + // all and has no user-facing chat to "Open". + // - background_task_agent: a user-configured agent that + // DOES notify, but exclusively through its own + // notify-user path; firing this ping too would + // duplicate that notification. + // (The finally block still runs on this early return.) + if ( + finalState.runUseCase === "knowledge_sync" || + finalState.runUseCase === "background_task_agent" + ) return; void notifyIfEnabled("chat_completion", { title: "Response ready", message: "Your agent finished responding.", diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 4ec1eb1b..3d80f0d2 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -99,6 +99,7 @@ import { getAccessToken } from "../../auth/tokens.js"; import { API_URL } from "../../config/env.js"; import type { IBrowserControlService } from "../browser-control/service.js"; import type { INotificationService } from "../notification/service.js"; +import { notifyIfEnabled } from "../notification/notifier.js"; // Parser libraries are loaded dynamically inside parseFile.execute() // to avoid pulling pdfjs-dist's DOM polyfills into the main bundle. // Import paths are computed so esbuild cannot statically resolve them. @@ -1659,13 +1660,44 @@ export const BuiltinTools: z.infer = { return false; } }, - execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }) => { + execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }, ctx?: ToolContext) => { try { const service = container.resolve('notificationService'); if (!service.isSupported()) { return { success: false, error: 'Notifications are not supported on this system' }; } - service.notify({ title, message, link, actionLabel, secondaryActions }); + let uc = getCurrentUseCase()?.useCase; + // ALS doesn't reliably propagate across the run's async generator, + // so when the in-context use-case is missing, fall back to the + // persisted use case on the run record via ctx.runId. + if (!uc && ctx?.runId) { + try { + const { fetchRun } = await import("../../runs/runs.js"); + const run = await fetchRun(ctx.runId); + uc = run.useCase; + } catch { + // best effort — fall through to the default branch + } + } + if (uc === 'background_task_agent') { + // User-configured background agent: gate behind the + // background_task category (toggleable), suppress the reopen + // flood, and default the deep-link to the background tasks + // page if the agent didn't supply its own link. + await notifyIfEnabled('background_task', { + title, + message, + link: link ?? 'rowboat://open?type=bg-tasks', + actionLabel, + secondaryActions, + suppressDuringStartupGrace: true, + onlyWhenBackground: true, + }); + } else { + // Regular chat (or any other) agent calling notify-user: + // notify directly as before. + service.notify({ title, message, link, actionLabel, secondaryActions }); + } return { success: true }; } catch (error) { return { diff --git a/apps/x/packages/core/src/application/notification/service.test.ts b/apps/x/packages/core/src/application/notification/service.test.ts new file mode 100644 index 00000000..038a5b02 --- /dev/null +++ b/apps/x/packages/core/src/application/notification/service.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { shouldSuppressDuringStartupGrace, STARTUP_GRACE_MS } from './service.js'; + +describe('shouldSuppressDuringStartupGrace (background-task reopen flood)', () => { + const launchedAt = 1_700_000_000_000; + + it('suppresses a grace-eligible notification fired inside the window', () => { + const now = launchedAt + STARTUP_GRACE_MS - 1; + expect(shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, now)).toBe(true); + }); + + it('lets a grace-eligible notification through once the window has passed', () => { + const now = launchedAt + STARTUP_GRACE_MS; + expect(shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, now)).toBe(false); + }); + + it('never suppresses a notification that is not grace-eligible', () => { + const now = launchedAt + 1; // well inside the window + expect(shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: false }, launchedAt, now)).toBe(false); + expect(shouldSuppressDuringStartupGrace({}, launchedAt, now)).toBe(false); + }); + + it('respects a custom grace window', () => { + const customWindow = 5_000; + expect( + shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, launchedAt + 4_999, customWindow), + ).toBe(true); + expect( + shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace: true }, launchedAt, launchedAt + 5_000, customWindow), + ).toBe(false); + }); +}); diff --git a/apps/x/packages/core/src/application/notification/service.ts b/apps/x/packages/core/src/application/notification/service.ts index 2d32878b..4eabb2c0 100644 --- a/apps/x/packages/core/src/application/notification/service.ts +++ b/apps/x/packages/core/src/application/notification/service.ts @@ -12,9 +12,40 @@ export interface NotifyInput { * regardless of focus (e.g. an agent permission request that blocks a run). */ onlyWhenBackground?: boolean; + /** + * When true, the notification is suppressed if it fires within the startup + * grace window (see STARTUP_GRACE_MS). This exists for notifications that a + * just-launched app can emit in a burst — most notably background-task + * completions: when the app reopens after being closed, every task that was + * queued while it was down completes at once and would otherwise flood the + * user. Fresh, user-driven activity happens after the window closes. + */ + suppressDuringStartupGrace?: boolean; } export interface INotificationService { isSupported(): boolean; notify(input: NotifyInput): void; } + +/** + * How long after launch grace-eligible notifications stay suppressed. Long + * enough to swallow the reopen burst of queued background tasks, short enough + * that a task genuinely finishing right after launch still pings the user. + */ +export const STARTUP_GRACE_MS = 60_000; + +/** + * Pure decision for the startup grace gate, kept out of the Electron service so + * it can be unit-tested without an Electron runtime. Returns true when the + * notification should be dropped because it is grace-eligible and we are still + * inside the window measured from `launchedAt`. + */ +export function shouldSuppressDuringStartupGrace( + input: Pick, + launchedAt: number, + now: number = Date.now(), + graceWindowMs: number = STARTUP_GRACE_MS, +): boolean { + return Boolean(input.suppressDuringStartupGrace) && now - launchedAt < graceWindowMs; +} diff --git a/apps/x/packages/core/src/background-tasks/runner.ts b/apps/x/packages/core/src/background-tasks/runner.ts index 39450d2e..dee147d8 100644 --- a/apps/x/packages/core/src/background-tasks/runner.ts +++ b/apps/x/packages/core/src/background-tasks/runner.ts @@ -6,6 +6,7 @@ import { getBackgroundTaskAgentModel } from '../models/defaults.js'; import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js'; import { buildTriggerBlock } from '../agents/build-trigger-block.js'; import { backgroundTaskBus } from './bus.js'; +import { withUseCase } from '../analytics/use_case.js'; const log = new PrefixLogger('BgTask:Agent'); @@ -183,7 +184,18 @@ export async function runBackgroundTask( }); try { - await createMessage(runId, buildMessage(slug, task, trigger, context, codeProject)); + // Establish the use-case context for the whole run so every tool the + // agent calls (notably notify-user) reads `background_task_agent` via + // getCurrentUseCase(). createMessage synchronously fires + // agentRuntime.trigger(), so the detached run loop — and the tool + // calls within it — inherit this AsyncLocalStorage context. (The + // runtime's own enterUseCase runs inside an async generator and + // doesn't reliably propagate to tool execution, so we set it here at + // the trigger point instead.) + await withUseCase( + { useCase: 'background_task_agent', subUseCase: trigger }, + () => createMessage(runId, buildMessage(slug, task, trigger, context, codeProject)), + ); await waitForRunCompletion(runId, { throwOnError: true }); const summary = await extractAgentResponse(runId); diff --git a/apps/x/packages/core/src/code-mode/sessions/status-tracker.ts b/apps/x/packages/core/src/code-mode/sessions/status-tracker.ts index 92abdf80..f6dc0fe9 100644 --- a/apps/x/packages/core/src/code-mode/sessions/status-tracker.ts +++ b/apps/x/packages/core/src/code-mode/sessions/status-tracker.ts @@ -2,9 +2,8 @@ import z from 'zod'; import { RunEvent } from '@x/shared/dist/runs.js'; import type { IBus } from '../../application/lib/bus.js'; import type { ICodeSessionsRepo } from './repo.js'; -import type { INotificationService } from '../../application/notification/service.js'; +import { notifyIfEnabled } from '../../application/notification/notifier.js'; import type { CodeSessionStatus, CodeSession } from '@x/shared/dist/code-sessions.js'; -import container from '../../di/container.js'; export type StatusListener = (sessionId: string, status: CodeSessionStatus) => void; @@ -107,17 +106,16 @@ export class CodeSessionStatusTracker { } private async notify(sessionId: string, previous: CodeSessionStatus, next: CodeSessionStatus): Promise { - let notificationService: INotificationService; - try { - notificationService = container.resolve('notificationService'); - } catch { - return; // not registered (e.g. tests) - } - if (!notificationService.isSupported()) return; + // Route through notifyIfEnabled so the user's notification-category + // toggles are honoured — a coding agent asking for approval maps to + // `agent_permission`, and one finishing its turn maps to + // `chat_completion`. notifyIfEnabled also resolves the service, checks + // platform support, and swallows errors, so a disabled toggle, missing + // service (e.g. tests), or unsupported platform all no-op safely. const session = await this.codeSessionsRepo.get(sessionId); const title = session?.title ?? 'Coding session'; if (next === 'needs-you') { - notificationService.notify({ + await notifyIfEnabled('agent_permission', { title, message: 'The coding agent needs your approval.', }); @@ -126,7 +124,7 @@ export class CodeSessionStatusTracker { // the user has plausibly moved on to something else. const since = this.busySince.get(sessionId); if (since !== undefined && Date.now() - since > 30_000) { - notificationService.notify({ + await notifyIfEnabled('chat_completion', { title, message: 'The coding agent finished its turn.', }); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.test.ts b/apps/x/packages/core/src/knowledge/sync_gmail.test.ts index 5da55bbc..8a0d307e 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.test.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + isEmailTooOldToNotify, + NEW_EMAIL_MAX_AGE_MS, sanitizeReplyBodyForGmailReply, stripGmailQuotedReplyHtml, stripGmailQuotedReplyText, @@ -40,3 +42,31 @@ describe('Gmail reply body sanitization', () => { expect(result.bodyHtml).toBe('

Sounds good, thanks.

'); }); }); + +describe('isEmailTooOldToNotify (stale backlog suppression)', () => { + const now = 1_700_000_000_000; + + it('suppresses emails older than the freshness window', () => { + const old = now - NEW_EMAIL_MAX_AGE_MS - 1; + expect(isEmailTooOldToNotify(old, now)).toBe(true); + }); + + it('notifies for emails within the freshness window', () => { + const recent = now - (NEW_EMAIL_MAX_AGE_MS - 1); + expect(isEmailTooOldToNotify(recent, now)).toBe(false); + }); + + it('notifies for emails exactly at the window boundary', () => { + expect(isEmailTooOldToNotify(now - NEW_EMAIL_MAX_AGE_MS, now)).toBe(false); + }); + + it('notifies when the email date is unknown (dateMs === 0)', () => { + // 0 means snapshotDateMs could not parse a date; err toward notifying + // rather than silently dropping genuinely-new mail. + expect(isEmailTooOldToNotify(0, now)).toBe(false); + }); + + it('notifies for a brand-new email (dateMs === now)', () => { + expect(isEmailTooOldToNotify(now, now)).toBe(false); + }); +}); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 52f8ca6f..7868a34e 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -221,20 +221,40 @@ function summarizeGmailSync(threads: SyncedThread[]): string { return lines.join('\n'); } +/** + * A "new email" notification for a message older than this is treated as a + * stale backlog item — e.g. Gmail replaying history after the app reopens from + * a long offline period — and suppressed, so a reopen doesn't surface day-old + * mail as if it just arrived. + */ +export const NEW_EMAIL_MAX_AGE_MS = 5 * 60 * 1000; + +/** + * True when an email is too old to be worth a "new email" ping. A `dateMs` of 0 + * means the age couldn't be determined, in which case we err toward notifying + * rather than risk silently dropping genuinely-new mail. + */ +export function isEmailTooOldToNotify(dateMs: number, now: number = Date.now()): boolean { + return dateMs > 0 && now - dateMs > NEW_EMAIL_MAX_AGE_MS; +} + /** * Fire one OS notification per genuinely-new email thread. Only ever called * from the partial-sync (incremental) path, so the first-time connect — which - * goes through fullSync — never notifies. Suppressed while the app is focused. + * goes through fullSync — never notifies. Suppressed while the app is focused, + * and for stale backlog (see isEmailTooOldToNotify). */ function notifyNewEmails(threads: SyncedThread[]): void { + const now = Date.now(); for (const { threadId } of threads) { const snapshot = readCachedSnapshot(threadId)?.snapshot; + if (snapshot && isEmailTooOldToNotify(snapshotDateMs(snapshot), now)) continue; const subject = snapshot?.subject?.trim() || '(no subject)'; const from = snapshot?.from?.trim(); void notifyIfEnabled('new_email', { title: from ? `New email from ${from}` : 'New email', message: subject, - link: 'rowboat://open?type=chat', + link: `rowboat://open?type=email&threadId=${threadId}`, actionLabel: 'Open', onlyWhenBackground: true, }); diff --git a/apps/x/packages/shared/src/notification-settings.ts b/apps/x/packages/shared/src/notification-settings.ts index 1ab7646b..33608fb6 100644 --- a/apps/x/packages/shared/src/notification-settings.ts +++ b/apps/x/packages/shared/src/notification-settings.ts @@ -6,17 +6,20 @@ import { z } from 'zod'; * - chat_completion: an agent finished generating a response * - new_email: a new email arrived during incremental Gmail sync * - agent_permission: an agent is requesting permission to run a tool + * - background_task: a background task agent pinged via the notify-user tool */ export const NotificationCategorySchema = z.enum([ 'chat_completion', 'new_email', 'agent_permission', + 'background_task', ]); export const NotificationCategoriesSchema = z.object({ chat_completion: z.boolean(), new_email: z.boolean(), agent_permission: z.boolean(), + background_task: z.boolean(), }); export const NotificationSettingsSchema = z.object({ @@ -28,6 +31,7 @@ export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { chat_completion: true, new_email: true, agent_permission: true, + background_task: true, }, }; From 0d069e8148e7d37eef48b4311e40c2c97823b899 Mon Sep 17 00:00:00 2001 From: hrsvrn Date: Wed, 24 Jun 2026 01:51:48 +0530 Subject: [PATCH 04/82] fix: respect dark mode in email compose/reply UI The compose modal/card hardcoded light-mode CSS variables unconditionally (introduced in 851c3be6 "gmail contacts should use light mode"), overriding the theme variables inherited from .gmail-shell. As a result the reply/compose box rendered as a white card with dark text even in dark mode. Split the single hardcoded block into theme-aware blocks following the existing .gmail-shell convention: dark values as the unprefixed default, light values under `.light`. Light mode is unchanged; dark mode now gets a proper dark card. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/x/apps/renderer/src/App.css | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index d8321855..2cb6ac33 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -726,8 +726,29 @@ background: rgba(0, 0, 0, 0.32); } +/* Dark defaults (mirrors .gmail-shell); .light overrides below. */ .gmail-compose-modal, .gmail-compose-card { + --gm-bg-card: #131317; + --gm-bg-input: #1c1c20; + --gm-bg-elevated: #1c1c20; + --gm-bg-pill: #1c1c20; + --gm-bg-pill-hover: #232328; + --gm-text: #e4e4e7; + --gm-text-strong: #fafafa; + --gm-text-muted: #71717a; + --gm-text-body: #d4d4d8; + --gm-border: #1f1f24; + --gm-border-strong: #2e2e35; + --gm-accent: #a78bfa; + --gm-accent-hover: #b9a6ff; + --gm-accent-fg: #18181b; + --gm-icon-hover-bg: #1f1f24; + --gm-placeholder: #52525b; +} + +.light .gmail-compose-modal, +.light .gmail-compose-card { --gm-bg-card: #ffffff; --gm-bg-input: #f4f4f7; --gm-bg-elevated: #ffffff; From 7e1acedfcff2b40c1d48ddef430ac81205280ce3 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:01:10 +0530 Subject: [PATCH 05/82] remove recents from new chat --- apps/x/apps/renderer/src/App.tsx | 3 - .../src/components/chat-empty-state.tsx | 55 ++----------------- .../renderer/src/components/chat-sidebar.tsx | 5 -- 3 files changed, 5 insertions(+), 58 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index ea575a26..fda27bf6 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6219,9 +6219,6 @@ function App() { {!tabHasConversation ? ( void navigateToView({ type: 'chat', runId: rid })} - onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} onPickPrompt={setPresetMessage} /> ) : ( diff --git a/apps/x/apps/renderer/src/components/chat-empty-state.tsx b/apps/x/apps/renderer/src/components/chat-empty-state.tsx index 9423d060..fca21600 100644 --- a/apps/x/apps/renderer/src/components/chat-empty-state.tsx +++ b/apps/x/apps/renderer/src/components/chat-empty-state.tsx @@ -1,19 +1,9 @@ -import { ArrowUpRight, Bot, Mail, MessageSquare, Sparkles, Telescope } from 'lucide-react' +import { Bot, Mail, Sparkles, Telescope } from 'lucide-react' import { cn } from '@/lib/utils' -import { formatRelativeTime } from '@/lib/relative-time' import { ToolConnectionsCard } from '@/components/tool-connections-card' -export interface ChatEmptyStateRun { - id: string - title?: string - createdAt: string -} - interface ChatEmptyStateProps { - recentRuns?: ChatEmptyStateRun[] - onSelectRun?: (runId: string) => void - onOpenChatHistory?: () => void /** Fill the composer with a starter prompt (does not submit). */ onPickPrompt: (prompt: string) => void /** Use a wider column — for the full-screen chat where the narrow column looks cramped. */ @@ -27,13 +17,10 @@ const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt ] /** - * Empty-state body for the chat surface: greeting, recent chats, and starter - * action cards. Shown in both the side-pane copilot and full-screen chat. + * Empty-state body for the chat surface: greeting and starter action cards. + * Shown in both the side-pane copilot and full-screen chat. */ export function ChatEmptyState({ - recentRuns = [], - onSelectRun, - onOpenChatHistory, onPickPrompt, wide = false, }: ChatEmptyStateProps) { @@ -45,45 +32,13 @@ export function ChatEmptyState({
What are we working on?
-
Ask anything, or pick up where you left off.
+
Ask anything, or start with a suggestion.
- {recentRuns.length > 0 && ( -
-
- Recent chats - {onOpenChatHistory && ( - - )} -
-
- {recentRuns.slice(0, 4).map((run) => ( - - ))} -
-
- )} -
- {recentRuns.length > 0 ? 'Or start fresh' : 'Get started'} + Get started
{SUGGESTED_ACTIONS.map((action) => ( diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index f0f29439..a463b080 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -674,11 +674,6 @@ export function ChatSidebar({ {!tabHasConversation ? ( ) : ( From aa347c90476aef3deabd5f5fe9730a3a1e09f839 Mon Sep 17 00:00:00 2001 From: gagan Date: Wed, 24 Jun 2026 15:35:22 +0530 Subject: [PATCH 06/82] fix(onboarding): center step connector line & refresh welcome layout (#640) * fix(onboarding): center step connector line with circles The connector line was vertically centered against the circle+label column, landing below the circle's center. Absolutely position the label below the circle so the row height equals just the circle and items-center aligns the line to the circle's center. * feat(onboarding): refresh welcome layout & indicator spacing - Place logo inline to the right of the Welcome heading - Move the tagline badge below the heading with more breathing room - Add space between the step indicator and step content --- .../components/onboarding/step-indicator.tsx | 6 ++-- .../onboarding/steps/welcome-step.tsx | 32 ++++++++----------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx index 6fae6dbb..8aa4e9e9 100644 --- a/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx @@ -26,7 +26,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) { const currentIndex = steps.findIndex(s => s.step === currentStep) return ( -
+
{steps.map((s, i) => ( {i > 0 && ( @@ -37,7 +37,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) { )} /> )} -
+
diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx index 9a660507..18dc7307 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx @@ -12,15 +12,21 @@ export function WelcomeStep({ state }: WelcomeStepProps) { return (
- {/* Logo with ambient glow */} + {/* Logo + main heading on the same level */} -
- Rowboat +

+ Welcome to Rowboat +

+ {/* Logo with ambient glow */} +
+
+ Rowboat +
{/* Tagline badge */} @@ -28,21 +34,11 @@ export function WelcomeStep({ state }: WelcomeStepProps) { initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} - className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-6" + className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-10" > Your AI coworker, with memory - - {/* Main heading */} - - Welcome to Rowboat - Date: Wed, 24 Jun 2026 15:42:31 +0530 Subject: [PATCH 07/82] fix(onboarding): inline completion checkmark & widen indicator gap (#641) - Remove the circular background and pulsing ring behind the completion checkmark; place the tick inline to the right of the "You're All Set!" heading - Increase spacing between the step indicator and step content --- .../components/onboarding/step-indicator.tsx | 2 +- .../onboarding/steps/completion-step.tsx | 37 +++++++------------ 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx index 8aa4e9e9..d0b41991 100644 --- a/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx @@ -26,7 +26,7 @@ export function StepIndicator({ currentStep, path }: StepIndicatorProps) { const currentIndex = steps.findIndex(s => s.step === currentStep) return ( -
+
{steps.map((s, i) => ( {i > 0 && ( diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx index 2b32309a..2f5d550c 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx @@ -13,34 +13,25 @@ export function CompletionStep({ state }: CompletionStepProps) { return (
- {/* Animated checkmark */} + {/* Title with checkmark on the right */} - {/* Pulsing ring */} - -
- -
-
- - {/* Title */} - - You're All Set! - +

+ You're All Set! +

+ + + +
Date: Wed, 24 Jun 2026 16:33:40 +0530 Subject: [PATCH 08/82] fix(byok): decouple chat model picker from config.models (#642) * fix(onboarding): BYOK saved entire model catalog as assistant models testAndSaveActiveProvider persisted the provider's full fetched catalog into config.models. That field is the user's curated assistant-model list (rendered as one row per entry in Settings > Models), so every available model showed up as a separate assistant-model dropdown. Seed it with just the selected model; users add more from Settings. * feat(byok): decouple chat model picker from config.models BYOK config.models was doing double duty: it both seeded the in-chat model picker AND was rendered as a per-entry dropdown stack in Settings. That coupling forced a tradeoff between a clean Settings UI and full model choice in chat, and baked a stale catalog snapshot into config. Decouple them so BYOK matches the signed-in experience: - Chat picker (signed-out) now lists the full live provider catalog from models:list, filtered to providers the user has a key/baseURL for, with the saved default model leading. No longer limited to config.models, so newly released models appear without re-saving. - Settings assistant model collapses to a single default-model dropdown (removed the .map() stack + add/remove). config.models is now just the one default; save/load/delete logic is unchanged. --- .../components/chat-input-with-mentions.tsx | 54 ++++++++--- .../onboarding/use-onboarding-state.ts | 12 ++- .../src/components/settings-dialog.tsx | 97 +++++-------------- 3 files changed, 74 insertions(+), 89 deletions(-) diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 412b4bbf..f3609212 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -401,18 +401,50 @@ function ChatInputInner({ } else { const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) const parsed = JSON.parse(result.data) + + // Offer every model the configured key supports — the same full catalog + // Settings uses for its dropdowns — so BYOK chat matches the signed-in + // gateway picker. The picker is no longer limited to a hand-curated + // config.models list. Providers with no catalog (Ollama, OpenAI-compatible) + // fall back to the model saved in config. + const catalog: Record = {} + try { + const listResult = await window.ipc.invoke('models:list', null) + for (const p of listResult.providers || []) { + catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id) + } + } catch { /* offline / no catalog — fall back to saved config below */ } + const models: ConfiguredModel[] = [] - if (parsed?.providers) { - for (const [flavor, entry] of Object.entries(parsed.providers)) { - const e = entry as Record - const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : [] - const singleModel = typeof e.model === 'string' ? e.model : '' - const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] - for (const model of allModels) { - if (model) { - models.push({ provider: flavor as ProviderName, model }) - } - } + const seen = new Set() + const push = (provider: string, model: string) => { + if (!model) return + const key = `${provider}/${model}` + if (seen.has(key)) return + seen.add(key) + models.push({ provider: provider as ProviderName, model }) + } + + // List the default provider first so its default model leads the picker. + const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : '' + const flavors = Object.keys(parsed?.providers || {}) + .sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0)) + + for (const flavor of flavors) { + const e = (parsed.providers[flavor] || {}) as Record + const hasKey = typeof e.apiKey === 'string' && (e.apiKey as string).trim().length > 0 + const hasBaseURL = typeof e.baseURL === 'string' && (e.baseURL as string).trim().length > 0 + if (!hasKey && !hasBaseURL) continue // provider not configured + + // The provider's saved default model leads, then the rest of its catalog. + push(flavor, typeof e.model === 'string' ? e.model : '') + const catalogModels = catalog[flavor] || [] + if (catalogModels.length > 0) { + for (const m of catalogModels) push(flavor, m) + } else { + // No catalog (local provider) — fall back to whatever is saved. + const saved = Array.isArray(e.models) ? e.models as string[] : [] + for (const m of saved) push(flavor, m) } } setConfiguredModels(models) diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index 12b880e4..17832f4b 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -429,13 +429,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { return false } - const models: string[] = result.models ?? [] + const catalog: string[] = result.models ?? [] const preferred = preferredDefaults[llmProvider] const model = - (preferred && models.includes(preferred) && preferred) || - models[0] || activeConfig.model.trim() || "" + (preferred && catalog.includes(preferred) && preferred) || + catalog[0] || activeConfig.model.trim() || "" - await window.ipc.invoke("models:saveConfig", { provider, model, models }) + // `models` is the user's curated assistant-model list (shown in Settings), + // NOT the full provider catalog. Onboarding seeds it with just the selected + // model; users add more from Settings. Persisting the whole catalog here + // rendered every model as a separate assistant-model row. + await window.ipc.invoke("models:saveConfig", { provider, model, models: model ? [model] : [] }) window.dispatchEvent(new Event('models-config-changed')) setTestState({ status: "success" }) setConnectedFlavors(prev => new Set(prev).add(llmProvider)) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 52873761..ef249836 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -384,38 +384,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { [] ) - const updateModelAt = useCallback( - (prov: LlmProviderFlavor, index: number, value: string) => { - setProviderConfigs(prev => { - const models = [...prev[prov].models] - models[index] = value - return { ...prev, [prov]: { ...prev[prov], models } } - }) - setTestState({ status: "idle" }) - }, - [] - ) - - const addModel = useCallback( - (prov: LlmProviderFlavor) => { - setProviderConfigs(prev => ({ - ...prev, - [prov]: { ...prev[prov], models: [...prev[prov].models, ""] }, - })) - }, - [] - ) - - const removeModel = useCallback( - (prov: LlmProviderFlavor, index: number) => { - setProviderConfigs(prev => { - const models = prev[prov].models.filter((_, i) => i !== index) - return { ...prev, [prov]: { ...prev[prov], models: models.length > 0 ? models : [""] } } - }) - setTestState({ status: "idle" }) - }, - [] - ) // Load current config from file useEffect(() => { @@ -727,48 +695,29 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
) : (
- {activeConfig.models.map((model, index) => ( -
- {showModelInput ? ( - updateModelAt(provider, index, e.target.value)} - placeholder="Enter model" - /> - ) : ( - - )} - {activeConfig.models.length > 1 && ( - - )} -
- ))} - + {showModelInput ? ( + updateConfig(provider, { models: [e.target.value] })} + placeholder="Enter model" + /> + ) : ( + + )}
)} {modelsError && ( From 6dbaa618e78112e2b2b178336de2847f337556d9 Mon Sep 17 00:00:00 2001 From: gagan Date: Wed, 24 Jun 2026 17:30:34 +0530 Subject: [PATCH 09/82] feat(onboarding): add Code Mode setup step (#643) * feat(onboarding): add Code Mode setup step Adds a new onboarding step (3, between Connect and Done; Completion moves to 4) shown in both Rowboat and BYOK paths. It informs the user up front that Code Mode needs Claude Code and/or Codex installed and signed in locally (claude login / codex login), then offers a master 'Enable code mode' toggle that reveals per-agent toggles. Downloads do NOT block onboarding: on Continue, selected-but-not-yet- installed engines are provisioned fire-and-forget in the main process, and their live % surfaces in Settings -> Code Mode. Approval policy is left at the default ('ask'); users adjust it later in Settings. The step is skippable like Connect Accounts. To make an onboarding-started download show its progress in Settings, the module-level provisioning store (startProvisioning/useProvisioning) is lifted out of settings-dialog.tsx into lib/code-mode-provisioning.ts and shared by both. * feat(onboarding): simplify Code Mode agent rows and refine copy - Show just the agent name (Claude Code / Codex) with a toggle and a green check when ready; drop the per-row download/sign-in clutter - Value-led description: use your existing Claude Code or Codex subscription inside Rowboat, signed in locally via the terminal --- .../src/components/onboarding/index.tsx | 3 + .../components/onboarding/step-indicator.tsx | 6 +- .../onboarding/steps/code-mode-step.tsx | 147 ++++++++++++++++++ .../onboarding/use-onboarding-state.ts | 10 +- .../src/components/settings-dialog.tsx | 61 +------- .../src/lib/code-mode-provisioning.ts | 63 ++++++++ 6 files changed, 225 insertions(+), 65 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/code-mode-step.tsx create mode 100644 apps/x/apps/renderer/src/lib/code-mode-provisioning.ts diff --git a/apps/x/apps/renderer/src/components/onboarding/index.tsx b/apps/x/apps/renderer/src/components/onboarding/index.tsx index d37cdf0f..2fb7426e 100644 --- a/apps/x/apps/renderer/src/components/onboarding/index.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/index.tsx @@ -14,6 +14,7 @@ import { StepIndicator } from "./step-indicator" import { WelcomeStep } from "./steps/welcome-step" import { LlmSetupStep } from "./steps/llm-setup-step" import { ConnectAccountsStep } from "./steps/connect-accounts-step" +import { CodeModeStep } from "./steps/code-mode-step" import { CompletionStep } from "./steps/completion-step" interface OnboardingModalProps { @@ -33,6 +34,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { case 2: return case 3: + return + case 4: return } }, [state.currentStep, state]) diff --git a/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx index d0b41991..661bdf8b 100644 --- a/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx @@ -6,14 +6,16 @@ import type { Step, OnboardingPath } from "./use-onboarding-state" const ROWBOAT_STEPS = [ { step: 0 as Step, label: "Welcome" }, { step: 2 as Step, label: "Connect" }, - { step: 3 as Step, label: "Done" }, + { step: 3 as Step, label: "Code" }, + { step: 4 as Step, label: "Done" }, ] const BYOK_STEPS = [ { step: 0 as Step, label: "Welcome" }, { step: 1 as Step, label: "Model" }, { step: 2 as Step, label: "Connect" }, - { step: 3 as Step, label: "Done" }, + { step: 3 as Step, label: "Code" }, + { step: 4 as Step, label: "Done" }, ] interface StepIndicatorProps { diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/code-mode-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/code-mode-step.tsx new file mode 100644 index 00000000..28b1f6a2 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/code-mode-step.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect, useCallback } from "react" +import { Loader2, ArrowLeft, Terminal, CheckCircle2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { startProvisioning, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning" +import type { OnboardingState } from "../use-onboarding-state" + +interface CodeModeStepProps { + state: OnboardingState +} + +const AGENTS = [ + { key: "claude" as const, name: "Claude Code" }, + { key: "codex" as const, name: "Codex" }, +] + +export function CodeModeStep({ state }: CodeModeStepProps) { + const { handleNext, handleBack } = state + + const [enabled, setEnabled] = useState(false) + const [selected, setSelected] = useState>({ claude: false, codex: false }) + const [status, setStatus] = useState(null) + const [statusLoading, setStatusLoading] = useState(true) + const [saving, setSaving] = useState(false) + + // Reflect what's already set up: pre-select installed agents and turn the master + // switch on if any agent is already there, so returning users don't start from off. + useEffect(() => { + let cancelled = false + ;(async () => { + setStatusLoading(true) + try { + const result = await window.ipc.invoke("codeMode:checkAgentStatus", null) + if (cancelled) return + setStatus(result) + const claudeInstalled = result.claude.installed + const codexInstalled = result.codex.installed + if (claudeInstalled || codexInstalled) { + setEnabled(true) + setSelected({ claude: claudeInstalled, codex: codexInstalled }) + } + } catch { + if (!cancelled) setStatus(null) + } finally { + if (!cancelled) setStatusLoading(false) + } + })() + return () => { cancelled = true } + }, []) + + const onContinue = useCallback(async () => { + if (enabled) { + setSaving(true) + try { + await window.ipc.invoke("codeMode:setConfig", { enabled: true, approvalPolicy: "ask" }) + window.dispatchEvent(new Event("code-mode-config-changed")) + } catch { + // Non-fatal — the user can still enable code mode later from Settings. + } + setSaving(false) + // Kick off engine downloads in the BACKGROUND for selected agents that aren't + // installed yet. We deliberately don't block onboarding on the ~200 MB download — + // it keeps running in the main process and its progress shows in Settings → Code Mode. + for (const a of AGENTS) { + if (selected[a.key] && !status?.[a.key].installed) { + startProvisioning(a.key, () => {}) + } + } + } + handleNext() + }, [enabled, selected, status, handleNext]) + + return ( +
+ {/* Title */} +

+ Set Up Code Mode +

+

+ Use your existing Claude Code or Codex subscription inside Rowboat to tackle coding + tasks and unlock far more workflows, all without leaving the app. Make sure Claude Code + and Codex are signed in locally with{" "} + claude login or{" "} + codex login in your terminal. +

+ + {statusLoading ? ( +
+ +
+ ) : ( +
+ {/* Master enable */} +
+
+
Enable code mode
+
+ Shows the code mode chip in the composer and lets the assistant delegate to your agents. +
+
+ +
+ + {/* Per-agent selection (revealed when enabled) */} + {enabled && ( +
+ + Agents to set up + + {AGENTS.map((a) => { + const st = status?.[a.key] + const ready = (st?.installed ?? false) && (st?.signedIn ?? false) + return ( +
+ +
{a.name}
+ {ready && } + setSelected((prev) => ({ ...prev, [a.key]: v }))} + /> +
+ ) + })} +
+ )} +
+ )} + + {/* Footer */} +
+ +
+ + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index 17832f4b..8e01aae8 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -8,7 +8,7 @@ export interface ProviderState { isConnecting: boolean } -export type Step = 0 | 1 | 2 | 3 +export type Step = 0 | 1 | 2 | 3 | 4 export type OnboardingPath = 'rowboat' | 'byok' | null @@ -377,8 +377,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { }, [startGoogleCalendarConnect]) // New step flow: - // Rowboat path: 0 (welcome) → 2 (connect) → 3 (done) - // BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (done) + // Rowboat path: 0 (welcome) → 2 (connect) → 3 (code mode) → 4 (done) + // BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (code mode) → 4 (done) const handleNext = useCallback(() => { if (currentStep === 0) { if (onboardingPath === 'byok') { @@ -390,6 +390,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { setCurrentStep(2) } else if (currentStep === 2) { setCurrentStep(3) + } else if (currentStep === 3) { + setCurrentStep(4) } }, [currentStep, onboardingPath]) @@ -403,6 +405,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { } else { setCurrentStep(1) } + } else if (currentStep === 3) { + setCurrentStep(2) } }, [currentStep, onboardingPath]) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index ef249836..6200cef9 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -26,6 +26,7 @@ import { toast } from "sonner" import { AccountSettings } from "@/components/settings/account-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" import type { ApprovalPolicy } from "@x/shared/src/code-mode.js" +import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning" type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help" @@ -1705,66 +1706,6 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { // --- Code Mode Settings --- -type AgentStatus = { installed: boolean; signedIn: boolean } -type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus } - -// Engine provisioning runs in the main process and keeps going even if the Settings -// dialog is closed. Track its state at MODULE level (not in the row component, which -// unmounts on close) so reopening Settings still shows the live % instead of the Enable -// button. A single persistent listener on the progress channel feeds this store. -type ProvState = { pct: number | null; error?: string } -const provStore: Record = {} -// Agents we provisioned this session — used to show "Ready" immediately on success -// without waiting for the async status refresh to round-trip (which caused the row to -// briefly flash the Enable button again). -const enabledOptimistic = new Set() -const provListeners = new Set<() => void>() -let provChannelHooked = false - -function notifyProv() { provListeners.forEach((l) => l()) } - -function startProvisioning(agent: 'claude' | 'codex', onDone: () => void | Promise): void { - if (provStore[agent] && !provStore[agent]!.error) return // already in flight - provStore[agent] = { pct: null } - notifyProv() - if (!provChannelHooked) { - provChannelHooked = true - window.ipc.on('codeMode:engineProgress', (p) => { - const cur = provStore[p.agent] - if (!cur) return - const pct = p.totalBytes ? Math.floor(((p.receivedBytes ?? 0) / p.totalBytes) * 100) : cur.pct - provStore[p.agent] = { pct } - notifyProv() - }) - } - window.ipc.invoke('codeMode:provisionEngine', { agent }) - .then((res) => { - if (res.success) { - // Mark installed optimistically so the row shows "Ready" the instant the flag - // clears — don't depend on the async status refresh (which re-renders the parent - // separately and left a window showing the Enable button). loadStatus still runs - // in the background to sync the real status. - enabledOptimistic.add(agent) - provStore[agent] = undefined - void onDone() - } else { - provStore[agent] = { pct: null, error: res.error ?? 'Failed to enable' } - } - }) - .catch((e) => { provStore[agent] = { pct: null, error: e instanceof Error ? e.message : 'Failed to enable' } }) - .finally(notifyProv) -} - -function useProvisioning(agent: string): ProvState | undefined { - const [, force] = useState(0) - useEffect(() => { - const l = () => force((n) => n + 1) - provListeners.add(l) - return () => { provListeners.delete(l) } - }, []) - return provStore[agent] -} - function AgentStatusRow({ name, agent, diff --git a/apps/x/apps/renderer/src/lib/code-mode-provisioning.ts b/apps/x/apps/renderer/src/lib/code-mode-provisioning.ts new file mode 100644 index 00000000..cbc3b922 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/code-mode-provisioning.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react" + +export type AgentStatus = { installed: boolean; signedIn: boolean } +export type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus } + +// Engine provisioning runs in the main process and keeps going even if the UI that +// started it (the Settings dialog OR the onboarding step) unmounts. Track its state at +// MODULE level — shared across both — so whichever view is mounted shows the live % +// instead of restarting. This is what lets a download kicked off from onboarding show +// its progress later in Settings → Code Mode. A single persistent listener on the +// progress channel feeds this store. +export type ProvState = { pct: number | null; error?: string } +const provStore: Record = {} +// Agents we provisioned this session — used to show "Ready" immediately on success +// without waiting for the async status refresh to round-trip (which caused the row to +// briefly flash the Enable button again). +export const enabledOptimistic = new Set() +const provListeners = new Set<() => void>() +let provChannelHooked = false + +function notifyProv() { provListeners.forEach((l) => l()) } + +export function startProvisioning(agent: 'claude' | 'codex', onDone: () => void | Promise): void { + if (provStore[agent] && !provStore[agent]!.error) return // already in flight + provStore[agent] = { pct: null } + notifyProv() + if (!provChannelHooked) { + provChannelHooked = true + window.ipc.on('codeMode:engineProgress', (p) => { + const cur = provStore[p.agent] + if (!cur) return + const pct = p.totalBytes ? Math.floor(((p.receivedBytes ?? 0) / p.totalBytes) * 100) : cur.pct + provStore[p.agent] = { pct } + notifyProv() + }) + } + window.ipc.invoke('codeMode:provisionEngine', { agent }) + .then((res) => { + if (res.success) { + // Mark installed optimistically so the row shows "Ready" the instant the flag + // clears — don't depend on the async status refresh (which re-renders the parent + // separately and left a window showing the Enable button). loadStatus still runs + // in the background to sync the real status. + enabledOptimistic.add(agent) + provStore[agent] = undefined + void onDone() + } else { + provStore[agent] = { pct: null, error: res.error ?? 'Failed to enable' } + } + }) + .catch((e) => { provStore[agent] = { pct: null, error: e instanceof Error ? e.message : 'Failed to enable' } }) + .finally(notifyProv) +} + +export function useProvisioning(agent: string): ProvState | undefined { + const [, force] = useState(0) + useEffect(() => { + const l = () => force((n) => n + 1) + provListeners.add(l) + return () => { provListeners.delete(l) } + }, []) + return provStore[agent] +} From a6572689b251bd0ec618f1c4c5faa49f958fb4a0 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:47:13 +0530 Subject: [PATCH 10/82] Update Readme (#644) --- README.md | 135 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 83 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 361b87a0..ade23320 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ - - rowboat-github-2 - -
+

Rowboat

+

A desktop AI coworker with a memory of your work and built-in surfaces to act on it.

+

rowboatlabs/rowboat | Trendshift @@ -25,28 +24,95 @@

-# Rowboat -**Open-source AI coworker that turns work into a knowledge graph and acts on it** - -Rowboat connects to your email and meeting notes, builds a long-lived knowledge graph, and uses that context to help you get work done - privately, on your machine. +Rowboat indexes your work into a living knowledge graph and uses that to get work done on your machine. It includes work surfaces for collaborating with AI: email client, notes, browser, code mode, meeting note taker, and workspaces for different projects. -You can do things like: -- `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph -- `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note) -- Track a person, company or topic through live notes -- Visualize, edit, and update your knowledge graph anytime (it’s just Markdown) -- Record voice memos that automatically capture and update key takeaways in the graph Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads) +

+ Screenshot 2026-06-24 at 11 40 45 PM +

+ + +

+ Demo - email to code · Demo - knowledge graph +

+ + ⭐ If you find Rowboat useful, please star the repo. It helps more people find it. -## Demo -[![Demo](https://github.com/user-attachments/assets/8b9a859b-d4f1-47ca-9d1d-9d26d982e15d)](https://www.youtube.com/watch?v=7xTpciZCfpw) +--- +## Overview -[Watch the full video](https://www.youtube.com/watch?v=7xTpciZCfpw) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Brain

+Rowboat indexes email, meetings, slack and assistant conversations into a living Obsidian-style backlinked knowledge graph. +
+Screenshot 2026-06-24 at 11 22 52 PM +
+

Email

+The built-in email client sorts emails into important and everything else. Rowboat automatically drafts responses for important email using all the work context. +
+Email screenshot +
+

Background agents

+You can set up background agents that run on events like new email or on schedule like every day at 8am. They can connect to tools, search the web, use the browser and write code using Claude Code or Codex. +
+Background agents screenshot + +
+

Built-in Browser

+Rowboat includes a browser that lets you and assistant collaborate on web tasks. Because its isolated from your main browser, you can log in only to the accounts that want the assistant to access. +
+Browser screenshot +
+

Meeting Notes

+A local meeting note-taker that taps into mic & speaker, produces live transcript and summarizes the meeting in a markdown file and updates the knowledge graph. +
+Meeting notes screenshot +
+

Code Mode

+Code mode lets you spin up parallel coding agents with Claude Code or Codex, and have Rowboat drive them with all the work context where needed. +
+Code mode screenshot +
+

Integrations

+Includes one-click integrations to most popular products. +
+Integrations screenshot +
--- @@ -81,23 +147,6 @@ All API key files use the same format: } ``` -## What it does - -Rowboat is a **local-first AI coworker** that can: -- **Remember** the important context you don’t want to re-explain (people, projects, decisions, commitments) -- **Understand** what’s relevant right now (before a meeting, while replying to an email, when writing a doc) -- **Help you act** by drafting, summarizing, planning, and producing real artifacts (briefs, emails, docs, PDF slides) - -Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Markdown notes with backlinks — a transparent “working memory” you can inspect and edit. - -## Integrations - -Rowboat builds memory from the work you already do, including: -- **Gmail** (email) -- **Google Calendar** -- **Rowboat meeting notes** or **Fireflies** - -It also contains a library of product integrations through Composio.dev ## How it’s different @@ -111,24 +160,6 @@ Rowboat maintains **long-lived knowledge** instead: The result is memory that compounds, rather than retrieval that starts cold every time. -## What you can do with it - -- **Meeting prep** from prior decisions, threads, and open questions -- **Email drafting** grounded in history and commitments -- **Docs & decks** generated from your ongoing context (including PDF slides) -- **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped -- **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions) - -## Live notes - -Live notes are notes that stay updated automatically. You can create one by typing '@rowboat' on a note. - -- Track a competitor or market topic across X, Reddit, and the news -- Monitor a person, project, or deal across web or your communications -- Keep a running summary of any subject you care about - -Everything is written back into your local Markdown vault. You control what runs and when. - ## Bring your own model Rowboat works with the model setup you prefer: From 40d684684c0e2e2be34055af291f93bcf42548bd Mon Sep 17 00:00:00 2001 From: Harshvardhan Vatsa <76729417+hrsvrn@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:40:34 +0530 Subject: [PATCH 11/82] feat(electron): add Cmd/Ctrl +/-/0 zoom shortcuts for the renderer (#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app sets no application menu, so it relied on Electron's default menu for the zoom accelerators — and on Linux that menu bar is suppressed by the frameless `hiddenInset` title bar, leaving the shortcuts unhandled. Wire them directly via `before-input-event`: Cmd/Ctrl + (+/=) zooms in, (-/_) zooms out, and 0 resets, clamped to zoom levels -3..3. `event.preventDefault()` stops the keystroke from leaking into the editor. --- apps/x/apps/main/src/main.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index f2843359..450d0f9e 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -216,6 +216,38 @@ function configureSessionPermissions(targetSession: Session): void { }); } +// Wire Ctrl/Cmd + (+ / − / 0) to zoom the renderer in/out/reset. +// The app sets no application menu, so the default menu's zoom roles aren't +// available — and on Linux the menu bar is suppressed by the frameless +// `hiddenInset` title bar — so handle the accelerators directly here. +// `event.preventDefault()` stops the keystroke from leaking into the editor. +function setupZoomShortcuts(win: BrowserWindow) { + const ZOOM_STEP = 0.5; // zoom-level units (factor = 1.2 ^ level, ~9.5% per step) + const MIN_ZOOM_LEVEL = -3; + const MAX_ZOOM_LEVEL = 3; + const wc = win.webContents; + + wc.on("before-input-event", (event, input) => { + if (input.type !== "keyDown") return; + // Cmd on macOS, Ctrl elsewhere. + if (!(process.platform === "darwin" ? input.meta : input.control)) return; + + // input.key is the produced character: "+"/"=" share a physical key (as do + // "-"/"_"), and numpad +/- produce the same characters, so this covers both. + const key = input.key; + if (key === "+" || key === "=") { + wc.setZoomLevel(Math.min(wc.getZoomLevel() + ZOOM_STEP, MAX_ZOOM_LEVEL)); + event.preventDefault(); + } else if (key === "-" || key === "_") { + wc.setZoomLevel(Math.max(wc.getZoomLevel() - ZOOM_STEP, MIN_ZOOM_LEVEL)); + event.preventDefault(); + } else if (key === "0") { + wc.setZoomLevel(0); + event.preventDefault(); + } + }); +} + function createWindow() { const win = new BrowserWindow({ width: 1280, @@ -292,6 +324,9 @@ function createWindow() { // The WebContentsView is created lazily on first `browser:setVisible`. browserViewManager.attach(win); + // Cmd/Ctrl + (+ / − / 0) zoom shortcuts for the renderer UI. + setupZoomShortcuts(win); + if (app.isPackaged) { win.loadURL("app://-/index.html"); } else { From 824610f84fe304319a34607ab1c36ec780c56ae8 Mon Sep 17 00:00:00 2001 From: hrsvrn Date: Fri, 26 Jun 2026 03:19:47 +0530 Subject: [PATCH 12/82] feat(email): restyle inbox, composer & reply to the Rowboat design system - Inbox (gmail-shell): map the bespoke --gm-* palette onto the app design tokens (background/foreground/border/accent/primary), drop the unloaded "Inter" font override so it inherits the app font, and remove the redundant ".light .gmail-shell" block so theming follows :root/.dark. - Composer: rebuilt on the shadcn Button/Input/Dialog primitives with app tokens; bring the AI describe-and-write bar and tone presets into reply mode. - Auto-generated reply block (editor.css): retokenize the Gmail-blue pill buttons and compose surface onto app tokens; inherit the editor font. - Add a Gmail-style "n" shortcut to open the composer from the inbox. --- apps/x/apps/renderer/src/App.css | 768 ++---------------- .../renderer/src/components/email-view.tsx | 407 ++++++---- apps/x/apps/renderer/src/styles/editor.css | 48 +- 3 files changed, 343 insertions(+), 880 deletions(-) diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 2cb6ac33..bdb1430f 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -82,64 +82,40 @@ background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px); } +/* Inbox surface — mapped onto the app's design tokens so it follows the same + palette, accent, and theme switching as the rest of Rowboat. Light/dark is + handled by the app tokens (:root / .dark), so no separate .light override. */ .gmail-shell { - --gm-bg: #0f0f12; - --gm-bg-card: #131317; - --gm-bg-input: #1c1c20; - --gm-bg-row-hover: #16161a; - --gm-bg-row-selected: #1a1620; - --gm-bg-row-selected-hover: #1d1825; - --gm-bg-iframe: #fafafa; - --gm-bg-pill: #1c1c20; - --gm-bg-pill-hover: #232328; - --gm-text: #e4e4e7; - --gm-text-strong: #fafafa; - --gm-text-muted: #71717a; - --gm-text-faint: #52525b; - --gm-text-body: #d4d4d8; - --gm-border: #1f1f24; - --gm-border-strong: #2e2e35; - --gm-accent: #a78bfa; - --gm-accent-hover: #b9a6ff; - --gm-accent-glow: rgba(167, 139, 250, 0.45); - --gm-accent-fg: #18181b; - --gm-icon-hover-bg: #1f1f24; - --gm-placeholder: #52525b; + --gm-bg: var(--background); + --gm-bg-card: var(--card-surface); + --gm-bg-input: var(--muted); + --gm-bg-row-hover: var(--accent); + --gm-bg-row-selected: var(--rowboat-wash); + --gm-bg-row-selected-hover: color-mix(in oklab, var(--primary) 18%, var(--background)); + --gm-bg-iframe: #ffffff; + --gm-bg-pill: var(--background); + --gm-bg-pill-hover: var(--accent); + --gm-text: var(--foreground); + --gm-text-strong: var(--foreground); + --gm-text-muted: var(--muted-foreground); + --gm-text-faint: color-mix(in oklab, var(--muted-foreground) 70%, var(--background)); + --gm-text-body: color-mix(in oklab, var(--foreground) 88%, var(--background)); + --gm-border: var(--border); + --gm-border-strong: var(--rowboat-hairline); + --gm-accent: var(--primary); + --gm-accent-hover: color-mix(in oklab, var(--primary) 90%, transparent); + --gm-accent-glow: color-mix(in oklab, var(--primary) 40%, transparent); + --gm-accent-fg: var(--primary-foreground); + --gm-icon-hover-bg: var(--accent); + --gm-placeholder: var(--muted-foreground); display: flex; height: 100%; min-height: 0; width: 100%; overflow: hidden; - background: var(--gm-bg); - color: var(--gm-text); - font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - font-feature-settings: "ss01", "cv11"; -} - -.light .gmail-shell { - --gm-bg: #ffffff; - --gm-bg-card: #ffffff; - --gm-bg-input: #f4f4f7; - --gm-bg-row-hover: #f7f7f9; - --gm-bg-row-selected: #f5f0ff; - --gm-bg-row-selected-hover: #ece4ff; - --gm-bg-iframe: #ffffff; - --gm-bg-pill: #ffffff; - --gm-bg-pill-hover: #f4f4f7; - --gm-text: #27272a; - --gm-text-strong: #09090b; - --gm-text-muted: #71717a; - --gm-text-faint: #a1a1aa; - --gm-text-body: #3f3f46; - --gm-border: #e4e4e7; - --gm-border-strong: #d4d4d8; - --gm-accent: #7c3aed; - --gm-accent-hover: #6d28d9; - --gm-accent-glow: rgba(124, 58, 237, 0.3); - --gm-accent-fg: #ffffff; - --gm-icon-hover-bg: #f4f4f7; - --gm-placeholder: #a1a1aa; + background: var(--background); + color: var(--foreground); } .gmail-main { @@ -326,7 +302,7 @@ } .gmail-row-action-danger:hover { - color: #e8453c; + color: var(--destructive); } .gmail-row:hover { @@ -714,714 +690,78 @@ border-color: var(--gm-border-strong); } -/* Standalone "new email" composer — centered modal popup */ -.gmail-compose-overlay { - position: fixed; - inset: 0; - z-index: 50; - display: flex; - align-items: center; - justify-content: center; - padding: 32px; - background: rgba(0, 0, 0, 0.32); -} - -/* Dark defaults (mirrors .gmail-shell); .light overrides below. */ -.gmail-compose-modal, -.gmail-compose-card { - --gm-bg-card: #131317; - --gm-bg-input: #1c1c20; - --gm-bg-elevated: #1c1c20; - --gm-bg-pill: #1c1c20; - --gm-bg-pill-hover: #232328; - --gm-text: #e4e4e7; - --gm-text-strong: #fafafa; - --gm-text-muted: #71717a; - --gm-text-body: #d4d4d8; - --gm-border: #1f1f24; - --gm-border-strong: #2e2e35; - --gm-accent: #a78bfa; - --gm-accent-hover: #b9a6ff; - --gm-accent-fg: #18181b; - --gm-icon-hover-bg: #1f1f24; - --gm-placeholder: #52525b; -} - -.light .gmail-compose-modal, -.light .gmail-compose-card { - --gm-bg-card: #ffffff; - --gm-bg-input: #f4f4f7; - --gm-bg-elevated: #ffffff; - --gm-bg-pill: #ffffff; - --gm-bg-pill-hover: #f4f4f7; - --gm-text: #27272a; - --gm-text-strong: #09090b; - --gm-text-muted: #71717a; - --gm-text-body: #3f3f46; - --gm-border: #e4e4e7; - --gm-border-strong: #d4d4d8; - --gm-accent: #7c3aed; - --gm-accent-hover: #6d28d9; - --gm-accent-fg: #ffffff; - --gm-icon-hover-bg: #f4f4f7; - --gm-placeholder: #a1a1aa; -} - -.gmail-compose-modal { - display: flex; - flex-direction: column; - width: min(840px, 100%); - height: min(720px, calc(100vh - 64px)); - max-height: calc(100vh - 64px); - border: 1px solid var(--gm-border-strong); - border-radius: 10px; - overflow: hidden; - background: var(--gm-bg-card); - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35); -} - -.gmail-compose-modal-header { - display: flex; - align-items: center; - gap: 10px; - height: 40px; - padding: 0 8px 0 14px; - background: var(--gm-bg-input); - color: var(--gm-text-body); - font-size: 12px; - font-weight: 600; - letter-spacing: 0.01em; - text-transform: uppercase; -} - -.gmail-compose-modal-header > span { - flex: 1; -} - -.gmail-compose-modal .gmail-compose-editor { - flex: 1; - min-height: 160px; - max-height: none; - padding: 0 14px; -} - -.gmail-compose-ai-bar { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - border-bottom: 1px solid var(--gm-border); -} - -.gmail-compose-ai-input { - flex: 1; - min-width: 0; - height: 30px; - padding: 0 10px; - border: 1px solid var(--gm-border-strong); - border-radius: 6px; - outline: none; - background: var(--gm-bg-input); - color: var(--gm-text); - font: inherit; - font-size: 12px; -} - -.gmail-compose-ai-presets { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 0 12px 10px; - border-bottom: 1px solid var(--gm-border); -} - -.gmail-compose-ai-presets button { - height: 24px; - padding: 0 10px; - border: 1px solid var(--gm-border-strong); - border-radius: 999px; - background: var(--gm-bg-pill); - color: var(--gm-text-muted); - font: inherit; - font-size: 11px; - font-weight: 500; - cursor: pointer; - transition: background 120ms ease, color 120ms ease, border-color 120ms ease; -} - -.gmail-compose-ai-presets button:hover:not(:disabled) { - background: var(--gm-bg-pill-hover); - border-color: var(--gm-accent); - color: var(--gm-accent); -} - -.gmail-compose-ai-presets button:disabled, -.gmail-compose-ai-input:disabled { - opacity: 0.5; - cursor: default; -} - -.gmail-compose-card { - max-width: 720px; - margin-left: 40px; - border: 1px solid var(--gm-border-strong); - border-radius: 8px; - overflow: hidden; - background: var(--gm-bg-card); -} - -.gmail-compose-header { - display: flex; - align-items: center; - justify-content: space-between; - height: 32px; - padding: 0 12px; - background: var(--gm-bg-input); - color: var(--gm-text-body); - font-size: 12px; - font-weight: 500; - letter-spacing: 0.01em; - text-transform: uppercase; -} - -.gmail-compose-header button, -.gmail-compose-link { - border: none; - background: transparent; - color: var(--gm-text-muted); - cursor: pointer; -} - -.gmail-compose-line { - display: flex; - align-items: center; - gap: 8px; - min-height: 32px; - padding: 0 12px; - border-bottom: 1px solid var(--gm-border); - color: var(--gm-text-muted); - font-size: 12px; -} - -.gmail-compose-line input { - min-width: 0; - flex: 1; - border: none; - outline: none; - background: transparent; - color: var(--gm-text); - font: inherit; -} - -.gmail-compose-label { - flex: none; - min-width: 28px; - color: var(--gm-text-muted); -} - -.gmail-compose-subject-input { - min-width: 0; - flex: 1; - border: none; - outline: none; - background: transparent; - color: var(--gm-text); - font: inherit; -} - -/* Recipient (To / Cc / Bcc) rows with editable chips */ -.gmail-recipient-row { - display: flex; - align-items: flex-start; - gap: 8px; - min-height: 34px; - padding: 5px 12px; - border-bottom: 1px solid var(--gm-border); - font-size: 13px; -} - -.gmail-recipient-label { - flex: none; - min-width: 28px; - padding-top: 5px; - color: var(--gm-text-muted); -} - -.gmail-recipient-field { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px; - flex: 1; - min-width: 0; - position: relative; -} - -.gmail-recipient-suggestions { - position: absolute; - top: calc(100% + 6px); - left: 0; - z-index: 30; - margin: 0; - padding: 6px; - list-style: none; - width: max-content; - min-width: 280px; - max-width: min(440px, 100%); - background: var(--gm-bg-elevated, #ffffff); - border: 1px solid var(--gm-border); - border-radius: 10px; - box-shadow: - 0 1px 2px rgba(24, 24, 27, 0.08), - 0 12px 32px rgba(24, 24, 27, 0.18); - max-height: 296px; - overflow-y: auto; - overscroll-behavior: contain; - transform-origin: top left; - animation: gmail-recipient-suggestions-in 110ms cubic-bezier(0.2, 0.7, 0.2, 1); -} - -@keyframes gmail-recipient-suggestions-in { - from { - opacity: 0; - transform: translateY(-2px) scale(0.985); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -.gmail-recipient-suggestion { - display: flex; - align-items: center; - gap: 10px; - padding: 6px 10px; - border-radius: 6px; - font-size: 13px; - color: var(--gm-text); - cursor: pointer; - transition: background-color 80ms linear; -} - -.gmail-recipient-suggestion:hover { - background: var(--gm-bg-pill-hover); -} - -.gmail-recipient-suggestion.is-active { - background: rgba(99, 142, 255, 0.18); -} - -.gmail-recipient-suggestion-avatar { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - border-radius: 50%; - color: #fff; - font-size: 12px; - font-weight: 600; - letter-spacing: 0.2px; - text-transform: uppercase; -} - -.gmail-recipient-suggestion-text { - display: flex; - flex-direction: column; - flex: 1 1 auto; - min-width: 0; - line-height: 1.25; -} - -.gmail-recipient-suggestion-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 500; -} - -.gmail-recipient-suggestion-email { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 11.5px; - color: var(--gm-text-muted); - margin-top: 1px; -} - -.gmail-recipient-suggestion-match { - background: transparent; - color: inherit; - font-weight: 700; - padding: 0; -} - -.gmail-recipient-chip { - display: inline-flex; - align-items: center; - gap: 4px; - max-width: 100%; - height: 24px; - padding: 0 4px 0 10px; - border-radius: 12px; - background: var(--gm-bg-pill); - color: var(--gm-text); - font-size: 12px; - line-height: 1; -} - -.gmail-recipient-chip-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 240px; -} - -.gmail-recipient-chip-remove { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border: none; - border-radius: 50%; - background: transparent; - color: var(--gm-text-muted); - font-size: 14px; - line-height: 1; - cursor: pointer; -} - -.gmail-recipient-chip-remove:hover { - background: var(--gm-bg-pill-hover); - color: var(--gm-text); -} - -.gmail-recipient-input { - flex: 1 1 80px; - min-width: 80px; - height: 24px; - border: none; - outline: none; - background: transparent; - color: var(--gm-text); - font: inherit; - font-size: 13px; -} - -.gmail-recipient-trailing { - flex: none; - padding-top: 5px; -} - -.gmail-recipient-toggles { - display: flex; - gap: 10px; -} - -.gmail-recipient-toggles button { - border: none; - background: transparent; - color: var(--gm-text-muted); - font: inherit; - font-size: 12px; - cursor: pointer; -} - -.gmail-recipient-toggles button:hover { - color: var(--gm-text); - text-decoration: underline; -} - -.gmail-compose-toolbar { - display: flex; - align-items: center; - gap: 2px; - flex: 1; - min-width: 0; - justify-content: flex-start; - padding-left: 10px; - margin-left: 2px; - border-left: 1px solid var(--gm-border-strong); -} - -.gmail-compose-link-popover { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 12px; - border-top: 1px solid var(--gm-border); - background: var(--gm-bg-input); -} - -.gmail-compose-link-popover input { - flex: 1; - min-width: 0; - height: 28px; - padding: 0 8px; - border: 1px solid var(--gm-border-strong); - border-radius: 4px; - background: var(--gm-bg-card); - color: var(--gm-text); - font: inherit; - font-size: 12px; - outline: none; -} - -.gmail-compose-link-popover input:focus { - border-color: var(--gm-accent); -} - -.gmail-compose-link-popover button { - height: 26px; - padding: 0 10px; - border: 1px solid var(--gm-border-strong); - border-radius: 4px; - background: var(--gm-bg-pill); - color: var(--gm-text); - font: inherit; - font-size: 12px; - cursor: pointer; -} - -.gmail-compose-link-popover button:hover { - background: var(--gm-bg-pill-hover); -} - -.gmail-compose-link-popover-apply { - background: var(--gm-accent) !important; - border-color: var(--gm-accent) !important; - color: var(--gm-accent-fg) !important; - font-weight: 600; -} - -.gmail-compose-link-popover-apply:hover { - background: var(--gm-accent-hover) !important; - border-color: var(--gm-accent-hover) !important; -} - -.gmail-compose-tool { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--gm-text-muted); - cursor: pointer; - transition: background 120ms ease, color 120ms ease; -} - -.gmail-compose-tool:hover:not(:disabled) { - background: var(--gm-bg-pill-hover); - color: var(--gm-text); -} - -.gmail-compose-tool:disabled { - opacity: 0.4; - cursor: default; -} - -.gmail-compose-tool.is-active { - background: var(--gm-bg-pill-hover); - color: var(--gm-accent); -} - -.gmail-compose-tool-sep { - display: inline-block; - width: 1px; - height: 18px; - margin: 0 6px; - background: var(--gm-border-strong); -} - -.gmail-compose-editor { - display: block; - width: 100%; - max-height: 360px; - overflow-y: auto; -} - -.gmail-compose-content { +/* Email composer body — Tiptap content, styled to the app's design system. */ +.compose-content { outline: none; min-height: 120px; padding: 12px; background: transparent; - color: var(--gm-text); - font: 13px/1.55 inherit; + color: var(--foreground); + font-size: 0.875rem; + line-height: 1.6; } -.gmail-compose-content p { +.compose-content p { margin: 0; } -.gmail-compose-content p + p, -.gmail-compose-content p + ul, -.gmail-compose-content p + ol, -.gmail-compose-content p + blockquote { +.compose-content p + p, +.compose-content p + ul, +.compose-content p + ol, +.compose-content p + blockquote { margin-top: 8px; } -.gmail-compose-content ul, -.gmail-compose-content ol { +.compose-content ul, +.compose-content ol { margin: 0; padding-left: 22px; } -.gmail-compose-content ul { +.compose-content ul { list-style: disc; } -.gmail-compose-content ol { +.compose-content ol { list-style: decimal; } -.gmail-compose-content li { +.compose-content li { margin: 2px 0; } -.gmail-compose-content li > p { +.compose-content li > p { margin: 0; } -.gmail-compose-content blockquote { +.compose-content blockquote { margin: 4px 0; padding-left: 12px; - border-left: 2px solid var(--gm-border-strong); - color: var(--gm-text-muted); + border-left: 2px solid var(--border); + color: var(--muted-foreground); } -.gmail-compose-content a { - color: var(--gm-accent); +.compose-content a { + color: var(--primary); text-decoration: underline; } -.gmail-compose-content code { +.compose-content code { padding: 1px 4px; border-radius: 3px; - background: var(--gm-bg-pill-hover); + background: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; + font-size: 0.8125rem; } -.gmail-compose-content p.is-editor-empty:first-child::before { +.compose-content p.is-editor-empty:first-child::before { content: attr(data-placeholder); - color: var(--gm-placeholder); + color: var(--muted-foreground); float: left; height: 0; pointer-events: none; } -.gmail-compose-attachments { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 8px 12px 0; -} - -.gmail-compose-attachment { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 240px; - padding: 4px 8px; - border: 1px solid var(--gm-border); - border-radius: 6px; - background: var(--gm-bg-pill); - font-size: 12px; - color: var(--gm-text); -} - -.gmail-compose-attachment-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.gmail-compose-attachment-size { - color: var(--gm-text-muted); - flex-shrink: 0; -} - -.gmail-compose-attachment-remove { - border: none; - background: transparent; - color: var(--gm-text-muted); - cursor: pointer; - font-size: 15px; - line-height: 1; - padding: 0 0 0 2px; - flex-shrink: 0; -} - -.gmail-compose-attachment-remove:hover { - color: var(--gm-text); -} - -.gmail-compose-actions { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 12px; - border-top: 1px solid var(--gm-border); -} - -.gmail-compose-actions-primary { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.gmail-refine-button { - display: inline-flex; - align-items: center; - gap: 8px; - box-sizing: border-box; - height: 30px; - padding: 0 14px; - border: 1px solid var(--gm-border-strong); - border-radius: 6px; - background: var(--gm-bg-pill); - color: var(--gm-text); - font: inherit; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease, color 120ms ease; -} - -.gmail-refine-button:hover { - background: var(--gm-bg-pill-hover); - border-color: var(--gm-accent); - color: var(--gm-accent); -} - -.gmail-send-button { - display: inline-flex; - align-items: center; - gap: 8px; - box-sizing: border-box; - height: 30px; - padding: 0 14px; - border: 1px solid transparent; - border-radius: 6px; - background: var(--gm-accent); - color: var(--gm-accent-fg); - font: inherit; - font-size: 12px; - font-weight: 600; - cursor: pointer; -} - -.gmail-send-button:hover { - background: var(--gm-accent-hover); -} - .gmail-empty-state { display: flex; align-items: center; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 5d62898a..266ae93c 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Strikethrough, Trash2, Undo2 } from 'lucide-react' +import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Strikethrough, Trash2, Undo2, X } from 'lucide-react' import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' @@ -9,6 +9,9 @@ import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { useTheme } from '@/contexts/theme-context' import { SettingsDialog } from '@/components/settings-dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' type GmailThread = blocks.GmailThread type GmailThreadMessage = blocks.GmailThreadMessage @@ -539,9 +542,11 @@ function ComposeToolbarButton({ children: React.ReactNode }) { return ( - + ) } function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () => void }) { return ( -
- - + - + + + editor.chain().focus().toggleBold().run()} isActive={editor.isActive('bold')} label="Bold" > - + - + - + - + editor.chain().focus().toggleBulletList().run()} isActive={editor.isActive('bulletList')} label="Bulleted list" > - + - + - + - - + +
) } @@ -678,7 +689,7 @@ function HighlightedText({ text, query }: { text: string; query: string }) { return ( <> {text.slice(0, idx)} - {text.slice(idx, idx + q.length)} + {text.slice(idx, idx + q.length)} {text.slice(idx + q.length)} ) @@ -812,26 +823,30 @@ function RecipientField({ const showSuggestions = isFocused && suggestions.length > 0 return ( -
- {label} -
+
+ {label} +
{value.map((token, index) => ( - - {recipientLabel(token)} + + {recipientLabel(token)} ))} setDraft(event.target.value)} onKeyDown={onKeyDown} @@ -854,7 +869,11 @@ function RecipientField({ }} /> {showSuggestions && ( -
    +
      {suggestions.map((c, idx) => { const hue = contactHue(c.email) return ( @@ -862,7 +881,10 @@ function RecipientField({ key={c.email} role="option" aria-selected={idx === activeIndex} - className={cn('gmail-recipient-suggestion', idx === activeIndex && 'is-active')} + className={cn( + 'flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-1.5 text-sm transition-colors', + idx === activeIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60' + )} onMouseDown={(event) => { // Prevent input blur before click fires. event.preventDefault() @@ -871,18 +893,18 @@ function RecipientField({ onMouseEnter={() => setActiveIndex(idx)} > - - + + {c.name && ( - + )} @@ -893,7 +915,7 @@ function RecipientField({
    )}
- {trailing &&
{trailing}
} + {trailing &&
{trailing}
}
) } @@ -1005,7 +1027,7 @@ const ComposeBox = memo(function ComposeBox({ }), ], editorProps: { - attributes: { class: 'gmail-compose-content' }, + attributes: { class: 'compose-content' }, }, content: initialContent, }) @@ -1054,16 +1076,17 @@ const ComposeBox = memo(function ComposeBox({ } // The signed-in account's display name, used to sign off AI-generated emails. + // Loaded in every mode (new, reply, forward) since the AI writer is available + // throughout and needs a name to sign off with. const [selfName, setSelfName] = useState('') const selfFirstName = useMemo(() => firstNameFromDisplayName(selfName), [selfName]) useEffect(() => { - if (!isNew) return let cancelled = false window.ipc.invoke('gmail:getAccountName', {}) .then((res) => { if (!cancelled && res?.name) setSelfName(res.name) }) .catch(() => {}) return () => { cancelled = true } - }, [isNew]) + }, []) const [aiPrompt, setAiPrompt] = useState('') const [generating, setGenerating] = useState(false) @@ -1101,6 +1124,18 @@ const ComposeBox = memo(function ComposeBox({ if (!instruction.trim()) { toast('Describe what to write.', 'error'); return } system = AI_GENERATE_SYSTEM const ctx: string[] = [] + // When replying or forwarding, give the model the thread it's responding + // to (oldest first) so the draft is on-topic and references the right + // points. New emails have no thread and skip this. + if (thread) { + const threadText = thread.messages + .map((message, index) => { + const header = message.from ? `From: ${message.from}\n` : '' + return `--- Message ${index + 1} ---\n${header}${(message.body || '').trim()}` + }) + .join('\n\n') + ctx.push(`This is a ${modeLabel.toLowerCase()} to the following email thread (oldest first):\n${threadText}`) + } // Use the recipients' names (from the contacts picker) so the AI can // address them naturally; fall back to the address when there's no name. const recipientNames = toList @@ -1111,7 +1146,7 @@ const ComposeBox = memo(function ComposeBox({ .filter(Boolean) if (recipientNames.length) ctx.push(`Recipient(s): ${recipientNames.join(', ')}`) if (selfFirstName) ctx.push(`Sender's first name (sign off as this): ${selfFirstName}`) - if (subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`) + if (isNew && subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`) if (current) ctx.push(`Existing draft (revise or build on it):\n${current}`) prompt = `${ctx.length ? ctx.join('\n') + '\n\n' : ''}Instruction: ${instruction.trim()}` } else { @@ -1135,15 +1170,18 @@ const ComposeBox = memo(function ComposeBox({ // draft lands in the editor's undo history and the toolbar's Undo reverts it. if (aiMode === 'generate') { const { subject: generatedSubject, body } = parseGeneratedEmail(res.text) - if (generatedSubject) setSubject(generatedSubject) + // Only new emails take the AI's subject; replies/forwards keep their + // derived "Re:"/"Fwd:" subject (and don't expose a subject field). + if (generatedSubject && isNew) setSubject(generatedSubject) // Always sign off with the account first name, even if the model omitted it. const signed = ensureSignature(body, selfFirstName) editor.chain().focus().selectAll().insertContent(plainTextToHtml(signed)).run() setHasGenerated(true) } else { - // Rewrites also regenerate the subject so it stays in sync with the body. + // Rewrites also regenerate the subject so it stays in sync with the body — + // but only for new emails, to preserve a reply/forward's threaded subject. const { subject: rewrittenSubject, body } = parseGeneratedEmail(res.text) - if (rewrittenSubject) setSubject(rewrittenSubject) + if (rewrittenSubject && isNew) setSubject(rewrittenSubject) editor.chain().focus().selectAll().insertContent(plainTextToHtml(body)).run() } } catch (err) { @@ -1301,84 +1339,100 @@ const ComposeBox = memo(function ComposeBox({ window.dispatchEvent(new Event('email-block:draft-with-assistant')) } - const card = ( -
event.stopPropagation() : undefined} - > -
- {modeLabel} - -
+ const inner = ( + <> - {!showCc && } - {!showBcc && } +
+ {!showCc && ( + + )} + {!showBcc && ( + + )}
} /> {showCc && } {showBcc && } - {isNew && ( - <> -
- setAiPrompt(event.target.value)} - placeholder={hasGenerated - ? 'Edit the draft (e.g. add a line about…, remove the last paragraph)…' - : 'Describe the email and let AI write it…'} - disabled={generating} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - void runAiBar() - } - }} - /> - -
-
- - {TONE_PRESETS.map((preset) => ( - - ))} -
- - )} +
+ setAiPrompt(event.target.value)} + placeholder={hasGenerated + ? 'Edit the draft (e.g. add a line about…, remove the last paragraph)…' + : isNew + ? 'Describe the email and let AI write it…' + : 'Describe your reply and let AI write it…'} + disabled={generating} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + void runAiBar() + } + }} + /> + +
+
+ + {TONE_PRESETS.map((preset) => ( + + ))} +
{(isNew || mode === 'forward') && ( -
- Subject +
+ Subject setSubject(event.target.value)} />
)} - + {attachments.length > 0 && ( -
+
{attachments.map((att) => ( -
- - {att.filename} - {formatAttachmentSize(att.size)} +
+ + {att.filename} + {formatAttachmentSize(att.size)} + >
))}
)} {linkOpen && ( -
event.preventDefault()}> - event.preventDefault()} + > + setLinkUrl(event.target.value)} placeholder="https://example.com" @@ -1423,58 +1485,99 @@ const ComposeBox = memo(function ComposeBox({ } }} /> - - + +
)} -
-
- - + {thread && ( - + )}
{editor && } - +
-
+ ) if (isNew) { return ( -
- {card} -
+ { if (!open) onClose() }}> + +
+ {modeLabel} + +
+ {inner} +
+
) } - return card + + return ( +
+
+ {modeLabel} + +
+ {inner} +
+ ) }) function ThreadDetail({ @@ -1691,6 +1794,30 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } }, []) + // Gmail-style "n" to start a new message. EmailView only mounts while the + // inbox is open, so this is naturally scoped to that view. Ignored while + // typing in any field or when a dialog (compose/settings) is already up. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'n' || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing) return + if (composeOpen || settingsOpen) return + const target = e.target as HTMLElement | null + if ( + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.isContentEditable) + ) { + return + } + e.preventDefault() + setComposeOpen(true) + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [composeOpen, settingsOpen]) + useEffect(() => { persistedImportant = important }, [important]) useEffect(() => { persistedOther = other }, [other]) diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index dbf9f067..9822a4ab 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -1551,9 +1551,9 @@ /* Compose / draft box */ .tiptap-editor .ProseMirror .email-gmail-compose { margin-top: 4px; - border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent); + border: 1px solid var(--border); border-radius: 8px; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08); + background: var(--background); overflow: hidden; } @@ -1562,13 +1562,13 @@ align-items: center; gap: 6px; padding: 8px 12px 6px; - border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); + border-bottom: 1px solid var(--border); } .tiptap-editor .ProseMirror .email-gmail-compose-to-label { font-size: 12px; font-weight: 500; - color: color-mix(in srgb, var(--foreground) 45%, transparent); + color: var(--muted-foreground); } .tiptap-editor .ProseMirror .email-gmail-compose-to-addr { @@ -1592,7 +1592,7 @@ } .tiptap-editor .ProseMirror .email-gmail-compose-body::placeholder { - color: color-mix(in srgb, var(--foreground) 35%, transparent); + color: var(--muted-foreground); } .tiptap-editor .ProseMirror .email-gmail-compose-footer { @@ -1600,7 +1600,7 @@ align-items: center; gap: 8px; padding: 8px 12px; - border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); + border-top: 1px solid var(--border); } /* Action buttons */ @@ -1615,35 +1615,33 @@ display: inline-flex; align-items: center; gap: 6px; - padding: 7px 16px; - font-size: 13px; + padding: 7px 12px; + font-size: 0.8125rem; font-weight: 500; - color: color-mix(in srgb, var(--foreground) 60%, transparent); + color: var(--foreground); background: transparent; - border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent); - border-radius: 18px; + border: 1px solid var(--border); + border-radius: 6px; cursor: pointer; - transition: background-color 0.15s ease, box-shadow 0.15s ease; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; width: fit-content; - letter-spacing: 0.01em; } .tiptap-editor .ProseMirror .email-gmail-btn:hover { - background: color-mix(in srgb, var(--foreground) 6%, transparent); - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1); - color: var(--foreground); + background: var(--accent); + color: var(--accent-foreground); } .tiptap-editor .ProseMirror .email-gmail-btn-primary { - color: #fff; - background: #1a73e8; - border-color: #1a73e8; + color: var(--primary-foreground); + background: var(--primary); + border-color: var(--primary); } .tiptap-editor .ProseMirror .email-gmail-btn-primary:hover { - background: #1765cc; - box-shadow: 0 1px 2px 0 rgba(26, 115, 232, 0.45), 0 1px 3px 1px rgba(26, 115, 232, 0.3); - color: #fff; + background: color-mix(in oklab, var(--primary) 90%, transparent); + border-color: color-mix(in oklab, var(--primary) 90%, transparent); + color: var(--primary-foreground); } .tiptap-editor .ProseMirror .email-block-error, @@ -1690,9 +1688,8 @@ /* ---- Emails inbox block (language-emails) ---- */ -.tiptap-editor .ProseMirror .email-inbox-card { - font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif; -} +/* No font-family override — inherit the editor's app font (ui-sans-serif / + system-ui) so the inbox block matches the rest of the Rowboat UI. */ .tiptap-editor .ProseMirror .email-inbox-title { font-size: 14px; @@ -1768,7 +1765,6 @@ .tiptap-editor .ProseMirror .email-inbox-sender { font-size: 14px; font-weight: 500; - font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif; color: var(--foreground); white-space: nowrap; overflow: hidden; From 4a26bc9d80d3bd6fdc70ee8a7fb322966f32b1c7 Mon Sep 17 00:00:00 2001 From: hrsvrn Date: Fri, 26 Jun 2026 03:59:04 +0530 Subject: [PATCH 13/82] feat(email): faster inbox load, Ctrl+Tab tab cycling, square preset buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inbox load: add an mtime-keyed in-memory cache to listInboxPage so it no longer re-reads and JSON.parses every cached thread (bodies included) on every call — only files whose mtime changed are re-parsed. Speeds up the Important→Everything-else handoff, live reloads during sync, and re-opening the inbox tab. - Add Ctrl+Tab / Ctrl+Shift+Tab to cycle to the next/previous tab (wraps around), alongside the existing Cmd+Shift+] / [ shortcuts. - Improve bar: make the tone preset buttons square (rounded-md), left-aligned, with a bit of vertical gap so wrapped rows aren't cramped. --- apps/x/apps/renderer/src/App.tsx | 19 +++++ .../renderer/src/components/email-view.tsx | 6 +- .../packages/core/src/knowledge/sync_gmail.ts | 76 +++++++++++++++---- 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fda27bf6..b6cd2e39 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -4727,6 +4727,25 @@ function App() { } return } + + // Ctrl+Tab — next tab, Ctrl+Shift+Tab — previous tab (browser-style). + // Bound to Ctrl specifically (Cmd+Tab is the OS app switcher on macOS). + if (e.ctrlKey && e.key === 'Tab') { + e.preventDefault() + const direction = e.shiftKey ? -1 : 1 + if (inFileView) { + const currentIdx = fileTabs.findIndex(t => t.id === targetFileTabId) + if (currentIdx === -1) return + const nextIdx = (currentIdx + direction + fileTabs.length) % fileTabs.length + switchFileTab(fileTabs[nextIdx].id) + } else { + const currentIdx = chatTabs.findIndex(t => t.id === activeChatTabId) + if (currentIdx === -1) return + const nextIdx = (currentIdx + direction + chatTabs.length) % chatTabs.length + switchChatTab(chatTabs[nextIdx].id) + } + return + } } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 266ae93c..8815723f 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -1398,12 +1398,12 @@ const ComposeBox = memo(function ComposeBox({ : (hasGenerated ? 'Edit' : 'Write')}
-
+
@@ -1413,7 +1413,7 @@ const ComposeBox = memo(function ComposeBox({ type="button" variant="outline" size="xs" - className="rounded-full" + className="rounded-md" onClick={() => { void runAi(preset.instruction, 'rewrite') }} disabled={generating} >{preset.label} diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 7868a34e..e4d478e9 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -615,11 +615,29 @@ export function listEverythingElseThreads(opts: { cursor?: string; limit?: numbe return listInboxPage({ section: 'other', ...opts }); } +// In-memory index of parsed snapshots, keyed by cache filename and validated by +// file mtime. listInboxPage runs on every inbox open, every "load more", and +// every throttled live reload during a sync — without this it re-reads and +// JSON.parses every cached thread (message bodies included) on each call. The +// cache lets unchanged files skip the read+parse, so e.g. the "Everything else" +// page right after "Important", reloads mid-sync, and re-opening the inbox cost +// a cheap stat() per file instead of a full parse of the whole cache. +interface ListCacheEntry { + mtimeMs: number; + dateMs: number; + section: InboxSection; + snapshot: GmailThreadSnapshot; +} +const listCache = new Map(); + export function listInboxPage(opts: InboxPageOptions): InboxPageResult { const limit = Math.max(1, Math.min(100, opts.limit ?? 25)); const cursor = parseCursor(opts.cursor); - if (!fs.existsSync(CACHE_DIR)) return { threads: [], nextCursor: null }; + if (!fs.existsSync(CACHE_DIR)) { + listCache.clear(); + return { threads: [], nextCursor: null }; + } let names: string[]; try { @@ -628,24 +646,56 @@ export function listInboxPage(opts: InboxPageOptions): InboxPageResult { return { threads: [], nextCursor: null }; } + const seen = new Set(); const entries: IndexedEntry[] = []; for (const name of names) { if (!name.endsWith('.json')) continue; + seen.add(name); const filePath = path.join(CACHE_DIR, name); + + let mtimeMs: number; try { - const raw = fs.readFileSync(filePath, 'utf-8'); - const wrapper = JSON.parse(raw) as SnapshotCacheEntry; - const snapshot = wrapper.snapshot; - if (!snapshot) continue; - if (snapshotImportance(snapshot) !== opts.section) continue; - entries.push({ - threadId: snapshot.threadId, - dateMs: snapshotDateMs(snapshot), - snapshot, - }); - } catch (err) { - console.warn(`[Inbox lists] read failed for ${name}:`, err); + mtimeMs = fs.statSync(filePath).mtimeMs; + } catch { + listCache.delete(name); + continue; } + + let cached = listCache.get(name); + if (!cached || cached.mtimeMs !== mtimeMs) { + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const wrapper = JSON.parse(raw) as SnapshotCacheEntry; + const snapshot = wrapper.snapshot; + if (!snapshot) { + listCache.delete(name); + continue; + } + cached = { + mtimeMs, + dateMs: snapshotDateMs(snapshot), + section: snapshotImportance(snapshot), + snapshot, + }; + listCache.set(name, cached); + } catch (err) { + console.warn(`[Inbox lists] read failed for ${name}:`, err); + listCache.delete(name); + continue; + } + } + + if (cached.section !== opts.section) continue; + entries.push({ + threadId: cached.snapshot.threadId, + dateMs: cached.dateMs, + snapshot: cached.snapshot, + }); + } + + // Evict cache entries for files that are gone (archived/trashed/pruned). + for (const key of listCache.keys()) { + if (!seen.has(key)) listCache.delete(key); } // Newest first, threadId asc as tiebreak. From 0887ee7fc0b6e5197839497d23ae5c2484109168 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:28:56 +0530 Subject: [PATCH 14/82] ability to add note to workspace --- apps/x/apps/renderer/src/App.tsx | 1 + .../src/components/workspace-view.tsx | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index b6cd2e39..87752392 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5918,6 +5918,7 @@ function App() { copyPath: knowledgeActions.copyPath, revealInFileManager: knowledgeActions.revealInFileManager, createNote: knowledgeActions.createNote, + addGoogleDoc: knowledgeActions.addGoogleDoc, createFolder: knowledgeActions.createFolder, onOpenInNewTab: knowledgeActions.onOpenInNewTab, }} diff --git a/apps/x/apps/renderer/src/components/workspace-view.tsx b/apps/x/apps/renderer/src/components/workspace-view.tsx index 6923ac1c..12362d59 100644 --- a/apps/x/apps/renderer/src/components/workspace-view.tsx +++ b/apps/x/apps/renderer/src/components/workspace-view.tsx @@ -27,6 +27,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { @@ -55,10 +56,26 @@ type WorkspaceActions = { copyPath: (path: string) => void revealInFileManager: (path: string, isDir: boolean) => void createNote: (parentPath?: string) => void + addGoogleDoc: (parentPath?: string) => void createFolder: (parentPath?: string) => Promise onOpenInNewTab?: (path: string) => void } +function GoogleDriveIcon({ className }: { className?: string }) { + return ( + + ) +} + type WorkspaceViewProps = { tree: TreeNode[] initialPath?: string | null @@ -359,6 +376,15 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo + actions.createNote(currentPath)}> + + New note + + actions.addGoogleDoc(currentPath)}> + + Add Google Doc + + filesInputRef.current?.click()}> Add files… From 753e3448f0ae4b4befcb78045d7ef942c6dd89a9 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:27:43 +0530 Subject: [PATCH 15/82] Show previous chats related to the workspace in the workspace section. --- apps/x/apps/main/src/ipc.ts | 3 + apps/x/apps/renderer/src/App.tsx | 1 + .../src/components/workspace-view.tsx | 110 +++++++++++++++++- apps/x/packages/core/src/runs/repo.ts | 93 +++++++++++++++ apps/x/packages/core/src/runs/runs.ts | 5 + apps/x/packages/shared/src/ipc.ts | 6 + 6 files changed, 212 insertions(+), 6 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 3ed9f6a0..a1f6c4a6 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -813,6 +813,9 @@ export function setupIpcHandlers() { 'runs:list': async (_event, args) => { return runsCore.listRuns(args.cursor); }, + 'runs:listByWorkDir': async (_event, args) => { + return runsCore.listRunsByWorkDir(args.dir); + }, 'runs:delete': async (_event, args) => { await runsCore.deleteRun(args.runId); return { success: true }; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 87752392..bfe71c85 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5925,6 +5925,7 @@ function App() { onNavigate={(path) => { void navigateToView({ type: 'workspace', path: path === WORKSPACE_ROOT ? undefined : path }) }} onOpenNote={(path) => navigateToFile(path)} onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }} + onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} />
) : isKnowledgeViewOpen ? ( diff --git a/apps/x/apps/renderer/src/components/workspace-view.tsx b/apps/x/apps/renderer/src/components/workspace-view.tsx index 12362d59..d5738e9b 100644 --- a/apps/x/apps/renderer/src/components/workspace-view.tsx +++ b/apps/x/apps/renderer/src/components/workspace-view.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronRight, Copy, @@ -9,6 +9,8 @@ import { FolderOpen, FolderPlus, Home, + Loader2, + MessageSquare, Pencil, Plus, Trash2, @@ -85,6 +87,24 @@ type WorkspaceViewProps = { onNavigate: (path: string) => void onOpenNote: (path: string) => void onCreateWorkspace: (name: string) => Promise + // Opens a previous chat (run) whose work directory is set to this workspace. + onOpenRun: (runId: string) => void +} + +type WorkspaceChat = { + id: string + title?: string + createdAt: string + modifiedAt: string +} + +function formatChatAt(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + const now = new Date() + const sameDay = d.toDateString() === now.toDateString() + if (sameDay) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) } function getFileManagerName(): string { @@ -143,9 +163,12 @@ function readFileAsBase64(file: File): Promise { }) } -export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) { +export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace, onOpenRun }: WorkspaceViewProps) { const currentPath = initialPath || WORKSPACE_ROOT const [addOpen, setAddOpen] = useState(false) + const [chatsOpen, setChatsOpen] = useState(false) + const [chats, setChats] = useState([]) + const [chatsLoading, setChatsLoading] = useState(false) const [newName, setNewName] = useState('') const [creating, setCreating] = useState(false) const [error, setError] = useState(null) @@ -182,6 +205,32 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo }) }, [currentPath, isRoot]) + // Load the chats whose work directory is this workspace folder (or nested + // inside it). The work directory is stored as an absolute path per run, so + // resolve this folder against the workspace root before querying. + const loadChats = useCallback(async () => { + if (isRoot) { + setChats([]) + return + } + setChatsLoading(true) + try { + const { root } = await window.ipc.invoke('workspace:getRoot', null) + const abs = `${root.replace(/\/$/, '')}/${currentPath}` + const { runs } = await window.ipc.invoke('runs:listByWorkDir', { dir: abs }) + setChats(runs.map((r) => ({ id: r.id, title: r.title, createdAt: r.createdAt, modifiedAt: r.modifiedAt }))) + } catch (err) { + console.error('Failed to load workspace chats:', err) + setChats([]) + } finally { + setChatsLoading(false) + } + }, [currentPath, isRoot]) + + useEffect(() => { + void loadChats() + }, [loadChats]) + const handleItemClick = useCallback( (item: TreeNode) => { if (renameTarget) return @@ -352,25 +401,34 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo ) })}
-
+
+ {!isRoot && ( + + )} {isRoot ? ( - ) : ( - @@ -422,6 +480,7 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo }} /> +
+ {!isRoot && chatsOpen && ( + + )} +
+ { diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index f08cac7c..c312b116 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -44,10 +44,46 @@ function runLogPath(runId: string): string { return path.join(WorkDir, 'runs', `${runId}.jsonl`); } +// Per-run work directory sidecar, written by the renderer when a chat's work +// directory is set (see `persistRunWorkDir` in the app). A run "belongs to" a +// workspace folder when its work directory is that folder or nested inside it. +function runWorkDirConfigPath(runId: string): string { + return path.join(WorkDir, 'config', `workdir-${runId}.json`); +} + +function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)); +} + +async function readRunWorkDir(runId: string): Promise { + try { + const raw = await fsp.readFile(runWorkDirConfigPath(runId), 'utf8'); + const parsed = JSON.parse(raw) as { path?: unknown }; + return typeof parsed?.path === 'string' && parsed.path ? parsed.path : null; + } catch { + return null; + } +} + +// Code-section sessions are runs (id == runId) but live in the Code view, not +// the chat list. Their metadata sidecar identifies them so they can be kept out +// of the workspace chats panel (opening one as a plain chat wouldn't resume code +// mode). See FSCodeSessionsRepo — one JSON file per session id. +async function isCodeSession(runId: string): Promise { + try { + await fsp.access(path.join(WorkDir, 'code-mode', 'sessions-meta', `${runId}.json`)); + return true; + } catch { + return false; + } +} + export interface IRunsRepo { create(options: CreateRunRepoOptions): Promise>; fetch(id: string): Promise>; list(cursor?: string): Promise>; + listByWorkDir(dir: string): Promise>; appendEvents(runId: string, events: z.infer[]): Promise; delete(id: string): Promise; } @@ -325,6 +361,63 @@ export class FSRunsRepo implements IRunsRepo { }; } + /** + * List runs whose work directory is `dir` or nested inside it. Unlike + * `list`, this scans every run (no pagination) and reads each run's + * work-directory sidecar to decide membership, so the caller gets the + * complete set of chats scoped to a workspace folder. Newest first. + */ + async listByWorkDir(dir: string): Promise> { + const target = path.resolve(dir); + const runsDir = path.join(WorkDir, 'runs'); + + let files: string[] = []; + try { + const entries = await fsp.readdir(runsDir, { withFileTypes: true }); + files = entries + .filter(e => e.isFile() && e.name.endsWith('.jsonl')) + .map(e => e.name); + } catch (err: unknown) { + const e = err as { code?: string }; + if (e.code === 'ENOENT') { + return { runs: [] }; + } + throw err; + } + + files.sort((a, b) => b.localeCompare(a)); + + const runs: z.infer['runs'] = []; + for (const name of files) { + const runId = name.slice(0, -'.jsonl'.length); + const workDir = await readRunWorkDir(runId); + if (!workDir || !isPathInside(target, path.resolve(workDir))) { + continue; + } + // Code-section sessions share the run/workdir machinery but belong + // in the Code view, not the workspace chats list. + if (await isCodeSession(runId)) { + continue; + } + const filePath = path.join(runsDir, name); + const metadata = await this.readRunMetadata(filePath); + if (!metadata) { + continue; + } + const stat = await fsp.stat(filePath); + runs.push({ + id: runId, + title: metadata.title, + createdAt: metadata.start.ts!, + modifiedAt: stat.mtime.toISOString(), + agentId: metadata.start.agentName, + ...(metadata.start.useCase ? { useCase: metadata.start.useCase } : {}), + }); + } + + return { runs }; + } + async delete(id: string): Promise { await fsp.unlink(runLogPath(id)); } diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 80d7f7c9..e61b2309 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -152,3 +152,8 @@ export async function listRuns(cursor?: string): Promise('runsRepo'); return repo.list(cursor); } + +export async function listRunsByWorkDir(dir: string): Promise> { + const repo = container.resolve('runsRepo'); + return repo.listByWorkDir(dir); +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index d1543006..25ad6a74 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -350,6 +350,12 @@ const ipcSchemas = { }), res: ListRunsResponse, }, + 'runs:listByWorkDir': { + req: z.object({ + dir: z.string(), + }), + res: ListRunsResponse, + }, 'runs:delete': { req: z.object({ runId: z.string(), From f2b5c6b1ab8b1f67c2556a4ef3dce26cd34b6aba Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:40:50 +0530 Subject: [PATCH 16/82] (1) add running transcript to voice input (2) capture till end of speech when mic is used (3) notify only on important emails --- apps/x/apps/renderer/src/App.tsx | 9 +-- .../components/chat-input-with-mentions.tsx | 31 ++++++--- .../renderer/src/components/chat-sidebar.tsx | 4 +- .../src/hooks/useMeetingTranscription.ts | 45 +++++++------ .../x/apps/renderer/src/hooks/useVoiceMode.ts | 65 +++++++++++++++++-- .../renderer/src/lib/deepgram-finalize.ts | 33 ++++++++++ .../packages/core/src/knowledge/sync_gmail.ts | 1 + 7 files changed, 147 insertions(+), 41 deletions(-) create mode 100644 apps/x/apps/renderer/src/lib/deepgram-finalize.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index bfe71c85..7ccb56e3 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1040,8 +1040,9 @@ function App() { const editorRefsByTabId = useRef>(new Map()) const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null) - const handleSubmitRecording = useCallback(() => { - const text = voice.submit() + const handleSubmitRecording = useCallback(async () => { + if (!isRecordingRef.current) return + const text = await voice.submit() setIsRecording(false) isRecordingRef.current = false if (text) { @@ -6377,7 +6378,7 @@ function App() { onWorkDirChange={(v) => setTabWorkDir(tab.id, v)} isRecording={isActive && isRecording} recordingText={isActive ? voice.interimText : undefined} - recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined} + recordingState={isActive ? (voice.state === 'submitting' ? 'stopping' : voice.state === 'connecting' ? 'connecting' : 'listening') : undefined} audioLevelsRef={voice.audioLevelsRef} onStartRecording={isActive ? handleStartRecording : undefined} onSubmitRecording={isActive ? handleSubmitRecording : undefined} @@ -6486,7 +6487,7 @@ function App() { collapsedLeftPaddingPx={collapsedLeftPaddingPx} isRecording={isRecording} recordingText={voice.interimText} - recordingState={voice.state === 'connecting' ? 'connecting' : 'listening'} + recordingState={voice.state === 'submitting' ? 'stopping' : voice.state === 'connecting' ? 'connecting' : 'listening'} audioLevelsRef={voice.audioLevelsRef} onStartRecording={handleStartRecording} onSubmitRecording={handleSubmitRecording} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index f3609212..182ed20a 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -223,11 +223,11 @@ interface ChatInputInnerProps { onDraftChange?: (text: string) => void isRecording?: boolean recordingText?: string - recordingState?: 'connecting' | 'listening' + recordingState?: 'connecting' | 'listening' | 'stopping' /** Live mic amplitude history (RMS per frame) driving the recording waveform. */ audioLevelsRef?: React.MutableRefObject onStartRecording?: () => void - onSubmitRecording?: () => void + onSubmitRecording?: () => void | Promise onCancelRecording?: () => void voiceAvailable?: boolean ttsAvailable?: boolean @@ -262,6 +262,7 @@ function ChatInputInner({ onDraftChange, isRecording, recordingText, + recordingState, audioLevelsRef, onStartRecording, onSubmitRecording, @@ -829,23 +830,33 @@ function ChatInputInner({ > - {/* Audio-reactive waveform only — the transcribed words are intentionally - not shown while recording; they're still captured and submitted. */} -
+
+
+ {recordingText?.trim() || (recordingState === 'stopping' ? 'Finalizing...' : 'Listening...')} +
) : ( @@ -1477,10 +1488,10 @@ export interface ChatInputWithMentionsProps { onDraftChange?: (text: string) => void isRecording?: boolean recordingText?: string - recordingState?: 'connecting' | 'listening' + recordingState?: 'connecting' | 'listening' | 'stopping' audioLevelsRef?: React.MutableRefObject onStartRecording?: () => void - onSubmitRecording?: () => void + onSubmitRecording?: () => void | Promise onCancelRecording?: () => void voiceAvailable?: boolean ttsAvailable?: boolean diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index a463b080..a9d6d9f7 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -177,10 +177,10 @@ interface ChatSidebarProps { // Voice / TTS props isRecording?: boolean recordingText?: string - recordingState?: 'connecting' | 'listening' + recordingState?: 'connecting' | 'listening' | 'stopping' audioLevelsRef?: React.MutableRefObject onStartRecording?: () => void - onSubmitRecording?: () => void + onSubmitRecording?: () => void | Promise onCancelRecording?: () => void voiceAvailable?: boolean ttsAvailable?: boolean diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index 96f42412..75b4d91f 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from 'react'; import { toast } from 'sonner'; import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url'; +import { finalizeDeepgramStream } from '@/lib/deepgram-finalize'; import { useRowboatAccount } from '@/hooks/useRowboatAccount'; export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping'; @@ -196,6 +197,25 @@ export function useMeetingTranscription(onAutoStop?: () => void) { }, 1000); }, [writeTranscriptToFile]); + const stopInputCapture = useCallback(() => { + if (processorRef.current) { + processorRef.current.disconnect(); + processorRef.current = null; + } + if (audioCtxRef.current) { + audioCtxRef.current.close(); + audioCtxRef.current = null; + } + if (micStreamRef.current) { + micStreamRef.current.getTracks().forEach(t => t.stop()); + micStreamRef.current = null; + } + if (systemStreamRef.current) { + systemStreamRef.current.getTracks().forEach(t => t.stop()); + systemStreamRef.current = null; + } + }, []); + const cleanup = useCallback(() => { if (writeTimerRef.current) { clearTimeout(writeTimerRef.current); @@ -213,28 +233,13 @@ export function useMeetingTranscription(onAutoStop?: () => void) { clearInterval(trackPollingRef.current); trackPollingRef.current = null; } - if (processorRef.current) { - processorRef.current.disconnect(); - processorRef.current = null; - } - if (audioCtxRef.current) { - audioCtxRef.current.close(); - audioCtxRef.current = null; - } - if (micStreamRef.current) { - micStreamRef.current.getTracks().forEach(t => t.stop()); - micStreamRef.current = null; - } - if (systemStreamRef.current) { - systemStreamRef.current.getTracks().forEach(t => t.stop()); - systemStreamRef.current = null; - } + stopInputCapture(); if (wsRef.current) { wsRef.current.onclose = null; wsRef.current.close(); wsRef.current = null; } - }, []); + }, [stopInputCapture]); const start = useCallback(async (calendarEvent?: CalendarEventMeta): Promise => { if (state !== 'idle') return null; @@ -551,12 +556,14 @@ export function useMeetingTranscription(onAutoStop?: () => void) { if (state !== 'recording') return; setState('stopping'); + stopInputCapture(); + await finalizeDeepgramStream(wsRef.current, 2200); cleanup(); - interimRef.current = new Map(); await writeTranscriptToFile(); + interimRef.current = new Map(); setState('idle'); - }, [state, cleanup, writeTranscriptToFile]); + }, [state, cleanup, stopInputCapture, writeTranscriptToFile]); return { state, start, stop }; } diff --git a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts index 8263e45d..a94a7eb8 100644 --- a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts +++ b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts @@ -1,10 +1,11 @@ import { useCallback, useRef, useState } from 'react'; import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url'; +import { finalizeDeepgramStream } from '@/lib/deepgram-finalize'; import { useRowboatAccount } from '@/hooks/useRowboatAccount'; import posthog from 'posthog-js'; import * as analytics from '@/lib/analytics'; -export type VoiceState = 'idle' | 'connecting' | 'listening'; +export type VoiceState = 'idle' | 'connecting' | 'listening' | 'submitting'; const DEEPGRAM_PARAMS = new URLSearchParams({ model: 'nova-3', @@ -129,8 +130,45 @@ export function useVoiceMode() { }; }, [refreshAuth]); - // Stop audio capture and close WS - const stopAudioCapture = useCallback(() => { + const waitForWsOpen = useCallback(async (timeoutMs = 1500): Promise => { + const ws = wsRef.current; + if (!ws) return false; + if (ws.readyState === WebSocket.OPEN) return true; + if (ws.readyState !== WebSocket.CONNECTING) return false; + + return new Promise((resolve) => { + let done = false; + let timeout: ReturnType; + const finish = (ok: boolean) => { + if (done) return; + done = true; + clearTimeout(timeout); + ws.removeEventListener('open', onOpen); + ws.removeEventListener('error', onError); + ws.removeEventListener('close', onClose); + resolve(ok); + }; + const onOpen = () => finish(true); + const onError = () => finish(false); + const onClose = () => finish(false); + timeout = setTimeout(() => finish(false), timeoutMs); + ws.addEventListener('open', onOpen); + ws.addEventListener('error', onError); + ws.addEventListener('close', onClose); + }); + }, []); + + const flushBufferedAudio = useCallback(() => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const buffered = audioBufferRef.current; + audioBufferRef.current = []; + for (const chunk of buffered) { + ws.send(chunk); + } + }, []); + + const stopInputCapture = useCallback(() => { if (processorRef.current) { processorRef.current.disconnect(); processorRef.current = null; @@ -143,6 +181,11 @@ export function useVoiceMode() { mediaStreamRef.current.getTracks().forEach(t => t.stop()); mediaStreamRef.current = null; } + }, []); + + // Stop audio capture and close WS + const stopAudioCapture = useCallback(() => { + stopInputCapture(); if (wsRef.current) { wsRef.current.onclose = null; wsRef.current.close(); @@ -155,7 +198,7 @@ export function useVoiceMode() { transcriptBufferRef.current = ''; interimRef.current = ''; setState('idle'); - }, []); + }, [stopInputCapture]); const start = useCallback(async () => { if (state !== 'idle') return; @@ -250,15 +293,25 @@ export function useVoiceMode() { }, [state, connectWs, stopAudioCapture]); /** Stop recording and return the full transcript (finalized + any current interim) */ - const submit = useCallback((): string => { + const submit = useCallback(async (): Promise => { + setState('submitting'); + stopInputCapture(); + + if (wsRef.current?.readyState === WebSocket.CONNECTING) { + await waitForWsOpen(); + } + flushBufferedAudio(); + await finalizeDeepgramStream(wsRef.current); + let text = transcriptBufferRef.current; if (interimRef.current) { text += (text ? ' ' : '') + interimRef.current; } text = text.trim(); + stopAudioCapture(); return text; - }, [stopAudioCapture]); + }, [flushBufferedAudio, stopAudioCapture, stopInputCapture, waitForWsOpen]); /** Cancel recording without returning transcript */ const cancel = useCallback(() => { diff --git a/apps/x/apps/renderer/src/lib/deepgram-finalize.ts b/apps/x/apps/renderer/src/lib/deepgram-finalize.ts new file mode 100644 index 00000000..91c3e37f --- /dev/null +++ b/apps/x/apps/renderer/src/lib/deepgram-finalize.ts @@ -0,0 +1,33 @@ +export async function finalizeDeepgramStream(ws: WebSocket | null, timeoutMs = 1800): Promise { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + await new Promise((resolve) => { + let done = false; + let timeout: ReturnType; + const finish = () => { + if (done) return; + done = true; + clearTimeout(timeout); + ws.removeEventListener('message', onMessage); + resolve(); + }; + timeout = setTimeout(finish, timeoutMs); + const onMessage = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data); + if (data?.is_final || data?.speech_final || data?.type === 'UtteranceEnd') { + finish(); + } + } catch { + // Ignore non-JSON control frames. + } + }; + + ws.addEventListener('message', onMessage); + try { + ws.send(JSON.stringify({ type: 'Finalize' })); + } catch { + finish(); + } + }); +} diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index e4d478e9..6b4018ec 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -248,6 +248,7 @@ function notifyNewEmails(threads: SyncedThread[]): void { const now = Date.now(); for (const { threadId } of threads) { const snapshot = readCachedSnapshot(threadId)?.snapshot; + if (snapshot?.importance !== 'important') continue; if (snapshot && isEmailTooOldToNotify(snapshotDateMs(snapshot), now)) continue; const subject = snapshot?.subject?.trim() || '(no subject)'; const from = snapshot?.from?.trim(); From e6ff631191325c9b420b0020ee8fc623c3ea88a7 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:57:05 +0530 Subject: [PATCH 17/82] fix codex unresponsive --- .../src/components/code/code-chat.tsx | 27 +++++++-- .../src/components/code/use-code-chat.ts | 38 +++++++++++++ .../renderer/src/components/coding-run.tsx | 6 +- .../packages/core/src/code-mode/acp/client.ts | 2 + apps/x/packages/shared/src/code-mode.ts | 5 ++ ...gentclientprotocol__codex-acp@0.0.44.patch | 57 +++++++++++++++++++ apps/x/pnpm-lock.yaml | 9 ++- apps/x/pnpm-workspace.yaml | 1 + 8 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 apps/x/patches/@agentclientprotocol__codex-acp@0.0.44.patch diff --git a/apps/x/apps/renderer/src/components/code/code-chat.tsx b/apps/x/apps/renderer/src/components/code/code-chat.tsx index 7b1656f3..6c34c295 100644 --- a/apps/x/apps/renderer/src/components/code/code-chat.tsx +++ b/apps/x/apps/renderer/src/components/code/code-chat.tsx @@ -103,7 +103,8 @@ export function CodeChat({ onOpenDiff: (path: string) => void }) { const { - items, liveText, isProcessing, pendingPermission, pendingToolPermissions, pendingAskHumans, + items, liveText, isProcessing, compactionStatus, contextUsage, + pendingPermission, pendingToolPermissions, pendingAskHumans, loading, send, stop, resolvePermission, respondToToolPermission, respondToAskHuman, } = useCodeChat(session) const [draft, setDraft] = useState('') @@ -111,6 +112,9 @@ export function CodeChat({ const textareaRef = useRef(null) const busy = isProcessing || status === 'working' || status === 'needs-you' + const contextUsedPercent = contextUsage + ? Math.min(100, Math.round((contextUsage.used / contextUsage.size) * 100)) + : null // Attached file PATHS — like dragging a file into the Claude Code CLI, the // agent receives paths and reads the files itself with its own tools. const [attachments, setAttachments] = useState([]) @@ -188,7 +192,10 @@ export function CodeChat({
{session.title}
-
{AGENT_LABEL[session.agent]} — direct
+
+ {AGENT_LABEL[session.agent]} — direct + {contextUsedPercent != null ? ` · ${contextUsedPercent}% context used` : ''} +
@@ -239,9 +246,19 @@ export function CodeChat({ /> ))} {busy && !pendingPermission && pendingToolPermissions.size === 0 && pendingAskHumans.size === 0 && ( - - {stopping ? 'Stopping…' : `${AGENT_LABEL[session.agent]} is working…`} - + compactionStatus === 'stalled' ? ( +
+ Context compaction is taking longer than expected. You can stop and retry in a fresh session. +
+ ) : ( + + {stopping + ? 'Stopping…' + : compactionStatus === 'running' + ? 'Compacting context…' + : `${AGENT_LABEL[session.agent]} is working…`} + + ) )} diff --git a/apps/x/apps/renderer/src/components/code/use-code-chat.ts b/apps/x/apps/renderer/src/components/code/use-code-chat.ts index 08468c49..ce8973c0 100644 --- a/apps/x/apps/renderer/src/components/code/use-code-chat.ts +++ b/apps/x/apps/renderer/src/components/code/use-code-chat.ts @@ -41,6 +41,10 @@ export interface PendingCodePermission { const DIRECT_PREFIX = 'direct-' const STRUCTURAL_EVENTS = new Set(['tool_call', 'tool_call_update', 'plan', 'permission']) +const COMPACTION_TITLE = 'Compacting context' +const COMPACTION_STALLED_MS = 90_000 + +export type CompactionStatus = 'idle' | 'running' | 'stalled' function messageText(content: unknown): string { if (typeof content === 'string') return content @@ -62,6 +66,8 @@ export function useCodeChat(session: CodeSession | null) { const [items, setItems] = useState([]) const [liveText, setLiveText] = useState('') const [isProcessing, setIsProcessing] = useState(false) + const [compactionStatus, setCompactionStatus] = useState('idle') + const [contextUsage, setContextUsage] = useState<{ used: number; size: number } | null>(null) const [pendingPermission, setPendingPermission] = useState(null) // Rowboat-mode copilot gates, same as the main chat: pre-tool-call permission // requests and ask-human questions. Keyed by toolCallId. @@ -69,6 +75,7 @@ export function useCodeChat(session: CodeSession | null) { const [pendingAskHumans, setPendingAskHumans] = useState>>(new Map()) const [loading, setLoading] = useState(false) const seenMessageIdsRef = useRef>(new Set()) + const compactionToolIdRef = useRef(null) const applyCodeRunEvent = useCallback((toolCallId: string, event: CodeRunEvent) => { if (toolCallId.startsWith(DIRECT_PREFIX)) { @@ -99,6 +106,9 @@ export function useCodeChat(session: CodeSession | null) { if (!sessionId) { setItems([]) setLiveText('') + setCompactionStatus('idle') + setContextUsage(null) + compactionToolIdRef.current = null setPendingPermission(null) return } @@ -106,6 +116,9 @@ export function useCodeChat(session: CodeSession | null) { setLoading(true) setItems([]) setLiveText('') + setCompactionStatus('idle') + setContextUsage(null) + compactionToolIdRef.current = null setPendingPermission(null) setPendingToolPermissions(new Map()) setPendingAskHumans(new Map()) @@ -217,6 +230,12 @@ export function useCodeChat(session: CodeSession | null) { return () => { cancelled = true } }, [sessionId]) + useEffect(() => { + if (compactionStatus !== 'running') return + const timer = window.setTimeout(() => setCompactionStatus('stalled'), COMPACTION_STALLED_MS) + return () => window.clearTimeout(timer) + }, [compactionStatus]) + // Live event stream. useEffect(() => { if (!sessionId) return @@ -230,6 +249,8 @@ export function useCodeChat(session: CodeSession | null) { break case 'run-processing-end': setIsProcessing(false) + setCompactionStatus('idle') + compactionToolIdRef.current = null setPendingPermission(null) // Anything still streaming that never landed as a message (e.g. the // turn errored) is flushed so the text isn't lost. @@ -247,6 +268,8 @@ export function useCodeChat(session: CodeSession | null) { break case 'run-stopped': setIsProcessing(false) + setCompactionStatus('idle') + compactionToolIdRef.current = null setPendingPermission(null) setPendingToolPermissions(new Map()) setPendingAskHumans(new Map()) @@ -348,6 +371,19 @@ export function useCodeChat(session: CodeSession | null) { break case 'code-run-event': { setIsProcessing(true) + if (event.event.type === 'usage') { + setContextUsage({ used: event.event.used, size: event.event.size }) + } + if (event.event.type === 'tool_call' && event.event.title === COMPACTION_TITLE) { + compactionToolIdRef.current = event.event.id ?? null + setCompactionStatus('running') + } + if (event.event.type === 'tool_call_update' + && event.event.id != null + && event.event.id === compactionToolIdRef.current) { + compactionToolIdRef.current = null + setCompactionStatus('idle') + } if (event.event.type === 'message' && event.event.role === 'agent' && event.toolCallId.startsWith(DIRECT_PREFIX)) { const text = event.event.text setLiveText((prev) => prev + text) @@ -460,6 +496,8 @@ export function useCodeChat(session: CodeSession | null) { items, liveText, isProcessing, + compactionStatus, + contextUsage, pendingPermission, pendingToolPermissions, pendingAskHumans, diff --git a/apps/x/apps/renderer/src/components/coding-run.tsx b/apps/x/apps/renderer/src/components/coding-run.tsx index ea620dc9..916aad37 100644 --- a/apps/x/apps/renderer/src/components/coding-run.tsx +++ b/apps/x/apps/renderer/src/components/coding-run.tsx @@ -9,6 +9,7 @@ import { Pencil, Search, ShieldQuestion, + Sparkles, Terminal, Trash2, Wrench, @@ -87,7 +88,8 @@ export function reduceEvents(events: CodeRunEvent[]): Row[] { return rows } -function toolKindIcon(kind?: string) { +function toolKindIcon(kind?: string, title?: string) { + if (title === 'Compacting context') return switch (kind) { case 'read': return case 'edit': return @@ -137,7 +139,7 @@ export function CodingRunTimeline({ {running ? : } - {toolKindIcon(row.toolKind)} + {toolKindIcon(row.toolKind, row.title)} {row.title ?? row.toolKind ?? 'Tool call'}
{row.diffs.length > 0 && ( diff --git a/apps/x/packages/core/src/code-mode/acp/client.ts b/apps/x/packages/core/src/code-mode/acp/client.ts index b78c5619..f7351604 100644 --- a/apps/x/packages/core/src/code-mode/acp/client.ts +++ b/apps/x/packages/core/src/code-mode/acp/client.ts @@ -139,6 +139,8 @@ function toEvent(update: SessionUpdate): CodeRunEvent { priority: e.priority ?? undefined, })), }; + case 'usage_update': + return { type: 'usage', used: update.used, size: update.size }; default: return { type: 'other', sessionUpdate: update.sessionUpdate }; } diff --git a/apps/x/packages/shared/src/code-mode.ts b/apps/x/packages/shared/src/code-mode.ts index a3bd46a7..8a85f62f 100644 --- a/apps/x/packages/shared/src/code-mode.ts +++ b/apps/x/packages/shared/src/code-mode.ts @@ -53,6 +53,11 @@ export const CodeRunEvent = z.discriminatedUnion("type", [ priority: z.string().optional(), })), }), + z.object({ + type: z.literal("usage"), + used: z.number().nonnegative(), + size: z.number().positive(), + }), z.object({ type: z.literal("permission"), ask: PermissionAsk, diff --git a/apps/x/patches/@agentclientprotocol__codex-acp@0.0.44.patch b/apps/x/patches/@agentclientprotocol__codex-acp@0.0.44.patch new file mode 100644 index 00000000..79ab1623 --- /dev/null +++ b/apps/x/patches/@agentclientprotocol__codex-acp@0.0.44.patch @@ -0,0 +1,57 @@ +diff --git a/dist/index.js b/dist/index.js +--- a/dist/index.js ++++ b/dist/index.js +@@ -17678,15 +17678,22 @@ + case "dynamicToolCall": + return await createDynamicToolCallUpdate(event.item); ++ case "contextCompaction": ++ return { ++ sessionUpdate: "tool_call", ++ toolCallId: event.item.id, ++ kind: "other", ++ title: "Compacting context", ++ status: "in_progress" ++ }; + case "collabAgentToolCall": + case "userMessage": + case "hookPrompt": + case "agentMessage": + case "reasoning": + case "webSearch": + case "imageView": + case "imageGeneration": + case "enteredReviewMode": + case "exitedReviewMode": +- case "contextCompaction": + case "plan": + return null; +@@ -17713,23 +17720,28 @@ + return this.completeCommandExecutionEvent(event.item); + case "reasoning": + const summary = event.item.summary[0]; + if (!summary) return null; + return { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: summary + } + }; ++ case "contextCompaction": ++ return { ++ sessionUpdate: "tool_call_update", ++ toolCallId: event.item.id, ++ status: "completed" ++ }; + case "collabAgentToolCall": + case "userMessage": + case "hookPrompt": + case "agentMessage": + case "webSearch": + case "imageView": + case "imageGeneration": + case "enteredReviewMode": + case "exitedReviewMode": +- case "contextCompaction": + case "plan": + return null; diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index dc18481a..5ad372b2 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -11,6 +11,9 @@ catalogs: version: 4.1.7 patchedDependencies: + '@agentclientprotocol/codex-acp@0.0.44': + hash: 0079b1ab3870451b47fe5ea5ecaca697e17139cff50fd2ee9c296b172aba525e + path: patches/@agentclientprotocol__codex-acp@0.0.44.patch '@openai/codex@0.128.0': hash: 9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86 path: patches/@openai__codex@0.128.0.patch @@ -57,7 +60,7 @@ importers: version: 0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1)) '@agentclientprotocol/codex-acp': specifier: ^0.0.44 - version: 0.0.44(zod@4.2.1) + version: 0.0.44(patch_hash=0079b1ab3870451b47fe5ea5ecaca697e17139cff50fd2ee9c296b172aba525e)(zod@4.2.1) '@x/core': specifier: workspace:* version: link:../../packages/core @@ -414,7 +417,7 @@ importers: version: 0.39.0(@anthropic-ai/sdk@0.100.1(zod@4.2.1))(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1)) '@agentclientprotocol/codex-acp': specifier: ^0.0.44 - version: 0.0.44(zod@4.2.1) + version: 0.0.44(patch_hash=0079b1ab3870451b47fe5ea5ecaca697e17139cff50fd2ee9c296b172aba525e)(zod@4.2.1) '@agentclientprotocol/sdk': specifier: ^0.22.1 version: 0.22.1(zod@4.2.1) @@ -8512,7 +8515,7 @@ snapshots: - '@anthropic-ai/sdk' - '@modelcontextprotocol/sdk' - '@agentclientprotocol/codex-acp@0.0.44(zod@4.2.1)': + '@agentclientprotocol/codex-acp@0.0.44(patch_hash=0079b1ab3870451b47fe5ea5ecaca697e17139cff50fd2ee9c296b172aba525e)(zod@4.2.1)': dependencies: '@agentclientprotocol/sdk': 0.21.1(zod@4.2.1) '@openai/codex': 0.128.0(patch_hash=9edd926108a95aaa788aa93870fd6b16d70eeccdf5740b503af5d34cc9f25e86) diff --git a/apps/x/pnpm-workspace.yaml b/apps/x/pnpm-workspace.yaml index 3f3b314a..c588042e 100644 --- a/apps/x/pnpm-workspace.yaml +++ b/apps/x/pnpm-workspace.yaml @@ -24,4 +24,5 @@ onlyBuiltDependencies: - macos-alias - protobufjs patchedDependencies: + '@agentclientprotocol/codex-acp@0.0.44': patches/@agentclientprotocol__codex-acp@0.0.44.patch '@openai/codex@0.128.0': patches/@openai__codex@0.128.0.patch From 470b75e1b6a8766fb5701f7aa9fb8dbf36aa60ce Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:05:39 +0530 Subject: [PATCH 18/82] code mode output sticks to bottom; mic input in direct drive; fix dark mode and minor margins (#649) --- apps/x/apps/renderer/src/App.css | 11 ++ apps/x/apps/renderer/src/App.tsx | 1 + .../components/ai-elements/conversation.tsx | 11 +- .../x/apps/renderer/src/components/code/cm.ts | 58 +++++- .../src/components/code/code-chat.tsx | 171 +++++++++++++++--- .../src/components/code/code-view.tsx | 14 +- .../src/components/code/terminal-pane.tsx | 10 +- 7 files changed, 242 insertions(+), 34 deletions(-) diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index bdb1430f..1fd8c287 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -1017,6 +1017,17 @@ var(--rowboat-shadow); } +.dark .rowboat-code-chat-input { + border-color: color-mix(in oklab, var(--border) 76%, transparent); + background: #000000; + box-shadow: none; +} + +.dark .rowboat-code-chat-input:focus-within { + border-color: color-mix(in oklab, var(--ring) 70%, var(--border) 30%); + box-shadow: 0 0 0 1px color-mix(in oklab, var(--ring) 30%, transparent); +} + [data-slot="message-content"] { line-height: 1.62; } diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7ccb56e3..cf1d3d8f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6413,6 +6413,7 @@ function App() { session={activeCodeSession.session} status={activeCodeSession.status} onOpenDiff={setCodeDiffPath} + voiceAvailable={voiceAvailable} /> ) : isRightPaneContext && ( diff --git a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx index 7a3f8836..6dfeedaf 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -43,6 +43,7 @@ export const Conversation = ({ const contentRef = useRef(null); const scrollRef = useRef(null); const spacerRef = useRef(null); + const stickToBottomRef = useRef(true); const [isAtBottom, setIsAtBottom] = useState(true); const updateBottomState = useCallback(() => { @@ -50,7 +51,9 @@ export const Conversation = ({ if (!container) return; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD_PX); + const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX; + stickToBottomRef.current = atBottom; + setIsAtBottom(atBottom); }, []); const applyAnchorLayout = useCallback( @@ -131,7 +134,12 @@ export const Conversation = ({ cancelAnimationFrame(rafId); } rafId = requestAnimationFrame(() => { + const shouldStick = !anchorMessageId && stickToBottomRef.current; applyAnchorLayout(false); + if (shouldStick) { + container.scrollTop = container.scrollHeight; + updateBottomState(); + } }); }; @@ -178,6 +186,7 @@ export const Conversation = ({ const container = scrollRef.current; if (!container) return; container.scrollTop = container.scrollHeight; + stickToBottomRef.current = true; updateBottomState(); }, [updateBottomState]); diff --git a/apps/x/apps/renderer/src/components/code/cm.ts b/apps/x/apps/renderer/src/components/code/cm.ts index 4b75c1b2..8fa523f7 100644 --- a/apps/x/apps/renderer/src/components/code/cm.ts +++ b/apps/x/apps/renderer/src/components/code/cm.ts @@ -29,6 +29,12 @@ const darkHighlight = HighlightStyle.define([ ]) export function cmBaseExtensions(isDark: boolean): Extension[] { + const bg = isDark ? '#0f1117' : '#ffffff' + const panelBg = isDark ? '#151821' : '#f6f8fa' + const text = isDark ? '#d4d4d8' : '#24292f' + const muted = isDark ? '#7d8590' : '#6e7781' + const border = isDark ? '#2f3542' : '#d0d7de' + return [ lineNumbers(), bracketMatching(), @@ -39,18 +45,62 @@ export function cmBaseExtensions(isDark: boolean): Extension[] { EditorView.theme( { '&': { - backgroundColor: 'transparent', + backgroundColor: bg, + color: text, fontSize: '12px', height: '100%', }, + '.cm-editor': { + backgroundColor: bg, + color: text, + }, + '.cm-content': { + caretColor: text, + }, '.cm-scroller': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', overflow: 'auto', }, + '.cm-line': { + color: text, + }, '.cm-gutters': { - backgroundColor: 'transparent', - border: 'none', - color: isDark ? '#6b7280' : '#9ca3af', + backgroundColor: panelBg, + borderRight: `1px solid ${border}`, + color: muted, + }, + '.cm-activeLine': { + backgroundColor: isDark ? 'rgba(110, 118, 129, 0.16)' : 'rgba(175, 184, 193, 0.18)', + }, + '.cm-activeLineGutter': { + backgroundColor: isDark ? 'rgba(110, 118, 129, 0.16)' : 'rgba(175, 184, 193, 0.18)', + }, + '.cm-selectionBackground, &.cm-focused .cm-selectionBackground, .cm-content ::selection': { + backgroundColor: isDark ? 'rgba(88, 166, 255, 0.32)' : 'rgba(9, 105, 218, 0.22)', + }, + '.cm-panels, .cm-panel': { + backgroundColor: panelBg, + color: text, + borderColor: border, + }, + '.cm-mergeView': { + backgroundColor: bg, + color: text, + }, + '.cm-mergeViewEditors': { + backgroundColor: bg, + }, + '.cm-mergeView .cm-editor': { + borderColor: border, + }, + '.cm-changedLine': { + backgroundColor: isDark ? 'rgba(56, 139, 253, 0.14)' : 'rgba(9, 105, 218, 0.08)', + }, + '.cm-deletedChunk': { + backgroundColor: isDark ? 'rgba(248, 81, 73, 0.14)' : 'rgba(255, 235, 233, 0.95)', + }, + '.cm-insertedLine, .cm-insertedChunk': { + backgroundColor: isDark ? 'rgba(63, 185, 80, 0.14)' : 'rgba(234, 255, 234, 0.95)', }, '&.cm-focused': { outline: 'none' }, // GitHub-style expander bar for folded unchanged regions (@codemirror/merge). diff --git a/apps/x/apps/renderer/src/components/code/code-chat.tsx b/apps/x/apps/renderer/src/components/code/code-chat.tsx index 6c34c295..28a55db5 100644 --- a/apps/x/apps/renderer/src/components/code/code-chat.tsx +++ b/apps/x/apps/renderer/src/components/code/code-chat.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from 'react' -import { ArrowUp, FileText, Loader2, LoaderIcon, Plus, Square, Terminal, X } from 'lucide-react' +import { useEffect, useRef, useState, type DragEvent, type MutableRefObject } from 'react' +import { ArrowUp, FileText, Loader2, LoaderIcon, Mic, Plus, Square, Terminal, X } from 'lucide-react' import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js' import { cn } from '@/lib/utils' import { toast } from 'sonner' @@ -15,9 +15,54 @@ import { CodeRunPermissionRequest, CodingRunTimeline } from '@/components/coding import { PermissionRequest } from '@/components/ai-elements/permission-request' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { WebSearchResult } from '@/components/ai-elements/web-search-result' +import { useVoiceMode } from '@/hooks/useVoiceMode' import { useCodeChat, isDirectTurn, isChatToolCall, isChatErrorMessage, type CodeChatItem } from './use-code-chat' const AGENT_LABEL: Record = { claude: 'Claude Code', codex: 'Codex' } +const WAVE_BAR_WIDTH = 3 +const WAVE_BAR_GAP = 2 +const WAVE_BAR_MIN = 1.5 +const WAVE_BAR_MAX = 18 + +function VoiceWaveform({ audioLevelsRef }: { audioLevelsRef: MutableRefObject }) { + const [bars, setBars] = useState([]) + + useEffect(() => { + let raf = 0 + let lastSig = '' + const tick = () => { + const levels = audioLevelsRef.current + const next = levels.length > 48 ? levels.slice(levels.length - 48) : levels + const sig = `${next.length}:${next.length ? next[next.length - 1] : 0}` + if (sig !== lastSig) { + lastSig = sig + setBars(next.slice()) + } + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [audioLevelsRef]) + + return ( +
+ {bars.map((level, i) => { + const amp = Math.min(1, Math.max(0, level)) ** 0.8 + return ( + + ) + })} +
+ ) +} function RowboatToolCall({ item, onOpenDiff }: { item: ToolCall; onOpenDiff: (path: string) => void }) { const [open, setOpen] = useState(false) @@ -97,10 +142,12 @@ export function CodeChat({ session, status, onOpenDiff, + voiceAvailable = false, }: { session: CodeSession status: CodeSessionStatus onOpenDiff: (path: string) => void + voiceAvailable?: boolean }) { const { items, liveText, isProcessing, compactionStatus, contextUsage, @@ -110,8 +157,12 @@ export function CodeChat({ const [draft, setDraft] = useState('') const [stopping, setStopping] = useState(false) const textareaRef = useRef(null) + const voice = useVoiceMode() + const voiceWarmup = voice.warmup const busy = isProcessing || status === 'working' || status === 'needs-you' + const recording = voice.state !== 'idle' + const recordingStopping = voice.state === 'submitting' const contextUsedPercent = contextUsage ? Math.min(100, Math.round((contextUsage.used / contextUsage.size) * 100)) : null @@ -123,6 +174,7 @@ export function CodeChat({ setDraft('') setAttachments([]) setStopping(false) + voice.cancel() textareaRef.current?.focus() }, [session.id]) @@ -130,6 +182,10 @@ export function CodeChat({ if (!busy) setStopping(false) }, [busy]) + useEffect(() => { + if (voiceAvailable) voiceWarmup() + }, [voiceAvailable, voiceWarmup]) + const addAttachments = (paths: string[]) => { const cleaned = paths.filter(Boolean) if (cleaned.length === 0) return @@ -145,7 +201,7 @@ export function CodeChat({ textareaRef.current?.focus() } - const handleDrop = (e: React.DragEvent) => { + const handleDrop = (e: DragEvent) => { if (!e.dataTransfer?.files?.length) return e.preventDefault() const paths = Array.from(e.dataTransfer.files) @@ -179,6 +235,27 @@ export function CodeChat({ await stop() } + const handleStartRecording = () => { + if (busy) return + void voice.start() + } + + const handleSubmitRecording = async () => { + if (!recording || recordingStopping) return + const text = await voice.submit() + if (!text) return + const result = await send(text) + if (!result.ok && result.error) { + toast.error(result.error) + setDraft(text) + } + } + + const handleCancelRecording = () => { + voice.cancel() + textareaRef.current?.focus() + } + const basename = (p: string) => p.split(/[\\/]/).pop() || p return ( @@ -266,8 +343,8 @@ export function CodeChat({ {/* Composer — mirrors the assistant chat input's look (rounded card, borderless textarea, round primary send / destructive stop). */} -
-
+
+
{attachments.length > 0 && (
{attachments.map((p) => ( @@ -290,22 +367,58 @@ export function CodeChat({ ))}
)} -
-