diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index ec60096f..787e28e6 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -16,9 +16,9 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 @@ -39,17 +39,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " @@ -61,25 +61,25 @@ jobs: # Create a temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) - + # Create keychain security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - + # Decode and import certificate echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - + # Allow codesign to access the keychain security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - + # Add keychain to search list security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain - + # Verify certificate was imported security find-identity -v "$KEYCHAIN_PATH" - + # Clean up certificate file rm -f $RUNNER_TEMP/certificate.p12 @@ -122,9 +122,9 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 @@ -145,17 +145,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " @@ -187,9 +187,9 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 @@ -212,17 +212,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index ad639a86..b6f15b66 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -56,6 +56,7 @@ module.exports = { description: 'AI coworker with memory', name: `Rowboat-win32-${arch}`, setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`, + setupIcon: path.join(__dirname, 'icons/icon.ico'), }) }, { @@ -66,7 +67,9 @@ module.exports = { bin: "rowboat", description: 'AI coworker with memory', maintainer: 'rowboatlabs', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } }) }, @@ -77,10 +80,27 @@ module.exports = { name: `Rowboat-linux`, bin: "rowboat", description: 'AI coworker with memory', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } } }, + { + name: require.resolve('./makers/maker-pacman.cjs'), + platforms: ['linux'], + config: { + name: 'rowboat', + bin: 'rowboat', + executableName: 'rowboat', + description: 'AI coworker with memory', + maintainer: 'rowboatlabs', + homepage: 'https://rowboatlabs.com', + license: 'Apache', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], + } + }, { name: '@electron-forge/maker-zip', platform: ["darwin", "win32", "linux"], diff --git a/apps/x/apps/main/icons/icon.ico b/apps/x/apps/main/icons/icon.ico new file mode 100644 index 00000000..0e5ac870 Binary files /dev/null and b/apps/x/apps/main/icons/icon.ico differ diff --git a/apps/x/apps/main/makers/maker-pacman.cjs b/apps/x/apps/main/makers/maker-pacman.cjs new file mode 100644 index 00000000..4cae1da9 --- /dev/null +++ b/apps/x/apps/main/makers/maker-pacman.cjs @@ -0,0 +1,134 @@ +// Custom Electron Forge maker that produces Arch Linux .pkg.tar.zst packages +// via makepkg. Runs only on Linux with makepkg available (i.e. an Arch host). +// +// CJS on purpose: forge.config.cjs require()s us. + +const path = require('path'); +const fs = require('fs'); +const { execSync } = require('child_process'); + +const MakerBase = require('@electron-forge/maker-base').default; + +const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64', ia32: 'i686', armv7l: 'armv7h' }; + +class MakerPacman extends MakerBase { + name = 'pacman'; + defaultPlatforms = ['linux']; + + isSupportedOnCurrentPlatform() { + if (process.platform !== 'linux') return false; + try { + execSync('command -v makepkg', { stdio: 'ignore' }); + return true; + } catch { + return false; + } + } + + async make({ dir, makeDir, targetArch, packageJSON, appName }) { + const pkgArch = ARCH_MAP[targetArch] || targetArch; + + const cfg = this.config || {}; + const pkgName = (cfg.name || appName || packageJSON.name).toLowerCase(); + // pacman pkgver disallows '-'; map prerelease tags through. + const pkgVersion = String(packageJSON.version || '0.0.0').replace(/-/g, '_'); + const pkgDesc = (cfg.description || packageJSON.description || '').replace(/"/g, '\\"'); + const maintainer = cfg.maintainer || 'unknown'; + const homepage = cfg.homepage || packageJSON.homepage || ''; + const license = cfg.license || 'custom'; + const bin = cfg.bin || pkgName; + const execName = cfg.executableName || appName || pkgName; + const mimeTypes = cfg.mimeType || []; + const depends = cfg.depends || []; + const iconSrc = cfg.icon; + + const outDir = path.resolve(path.join(makeDir, 'pacman', targetArch)); + await this.ensureDirectory(outDir); + + // Clean prior contents so makepkg starts fresh each run. + for (const f of fs.readdirSync(outDir)) { + fs.rmSync(path.join(outDir, f), { recursive: true, force: true }); + } + + // Wrapper script — execs the packaged Electron binary, forwards args (incl. rowboat:// URLs). + fs.writeFileSync( + path.join(outDir, bin), + `#!/bin/sh\nexec "/opt/${pkgName}/${execName}" "$@"\n`, + { mode: 0o755 }, + ); + + const desktop = [ + '[Desktop Entry]', + `Name=${appName || pkgName}`, + `Comment=${pkgDesc}`, + `Exec=${bin} %U`, + `Icon=${pkgName}`, + 'Type=Application', + 'Categories=Utility;', + 'Terminal=false', + mimeTypes.length ? `MimeType=${mimeTypes.join(';')};` : null, + '', + ].filter(Boolean).join('\n'); + fs.writeFileSync(path.join(outDir, `${pkgName}.desktop`), desktop); + + const sources = [bin, `${pkgName}.desktop`]; + let iconInstall = ''; + if (iconSrc && fs.existsSync(iconSrc)) { + fs.copyFileSync(iconSrc, path.join(outDir, 'icon.png')); + sources.push('icon.png'); + iconInstall = ` install -Dm644 "$srcdir/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/${pkgName}.png"`; + } + + const sumsLine = sources.map(() => "'SKIP'").join(' '); + const sourceLine = sources.map((s) => `'${s}'`).join(' '); + const dependsLine = depends.map((d) => `'${d}'`).join(' '); + // Embed the packager output dir as a bash-safe literal. + const appDirEscaped = dir.replace(/'/g, `'\\''`); + + const pkgbuild = `# Maintainer: ${maintainer} +# Auto-generated by maker-pacman.cjs — do not edit by hand. +pkgname=${pkgName} +pkgver=${pkgVersion} +pkgrel=1 +pkgdesc="${pkgDesc}" +arch=('${pkgArch}') +url="${homepage}" +license=('${license}') +depends=(${dependsLine}) +options=('!strip' '!debug') +source=(${sourceLine}) +sha256sums=(${sumsLine}) + +_appdir='${appDirEscaped}' + +package() { + install -dm755 "$pkgdir/opt/$pkgname" + cp -a "$_appdir/." "$pkgdir/opt/$pkgname/" + + # Electron's sandbox helper needs setuid root for the namespace sandbox. + if [ -f "$pkgdir/opt/$pkgname/chrome-sandbox" ]; then + chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox" + fi + + install -Dm755 "$srcdir/${bin}" "$pkgdir/usr/bin/${bin}" + install -Dm644 "$srcdir/${pkgName}.desktop" "$pkgdir/usr/share/applications/${pkgName}.desktop" +${iconInstall} +} +`; + fs.writeFileSync(path.join(outDir, 'PKGBUILD'), pkgbuild); + + execSync('makepkg -f --noconfirm --nodeps', { + cwd: outDir, + stdio: 'inherit', + env: { ...process.env, PKGEXT: '.pkg.tar.zst', CARCH: pkgArch }, + }); + + return fs + .readdirSync(outDir) + .filter((f) => f.endsWith('.pkg.tar.zst')) + .map((f) => path.join(outDir, f)); + } +} + +module.exports = MakerPacman; +module.exports.default = MakerPacman; diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 74cb1598..b6edd064 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -13,6 +13,8 @@ "make": "electron-forge make" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.39.0", + "@agentclientprotocol/codex-acp": "^0.0.44", "@x/core": "workspace:*", "@x/shared": "workspace:*", "chokidar": "^4.0.3", @@ -27,6 +29,7 @@ }, "devDependencies": { "@electron-forge/cli": "^7.10.2", + "@electron-forge/maker-base": "^7.11.1", "@electron-forge/maker-deb": "^7.11.1", "@electron-forge/maker-dmg": "^7.10.2", "@electron-forge/maker-rpm": "^7.11.1", diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 1675b78b..9a03f4fb 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -33,6 +33,7 @@ import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; +import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js'; import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js'; import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; @@ -56,6 +57,8 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; +import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js'; +import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -586,6 +589,13 @@ export function setupIpcHandlers() { // Forward knowledge commit events to renderer for panel refresh versionHistory.onCommit(() => emitKnowledgeCommitEvent()); + // Pre-warm the Gmail contact indices so the first compose-box keystroke is instant. + // - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage). + // - warmSentContacts(): kicks off a background Gmail API sync of the SENT label + // for full historical coverage of people you've actually emailed. + warmContactIndex(); + warmSentContacts(); + registerIpcHandlers({ 'app:getVersions': async () => { // args is null for this channel (no request payload) @@ -663,6 +673,22 @@ export function setupIpcHandlers() { saveMessageBodyHeight(args.threadId, args.messageId, args.height); return {}; }, + 'gmail:searchContacts': async (_event, args) => { + const query = args?.query ?? ''; + const limit = args?.limit; + const excludeEmails = args?.excludeEmails; + + // Primary source: people you've actually sent mail to (Gmail SENT label, + // cached + refreshed via the Gmail API). Fallback: local-snapshot index + // — used only when the SENT index hasn't been populated yet (very first + // launch, before the background sync finishes). + const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []); + if (sent.length > 0) { + return { contacts: sent }; + } + const fallback = await searchGmailContacts(query, { limit, excludeEmails }); + return { contacts: fallback }; + }, 'mcp:listTools': async (_event, args) => { return mcpCore.listTools(args.serverName, args.cursor); }, @@ -679,6 +705,11 @@ export function setupIpcHandlers() { await runsCore.authorizePermission(args.runId, args.authorization); return { success: true }; }, + 'codeRun:resolvePermission': async (_event, args) => { + const registry = container.resolve('codePermissionRegistry'); + registry.resolve(args.requestId, args.decision); + return { success: true }; + }, 'runs:provideHumanInput': async (_event, args) => { await runsCore.replyToHumanInputRequest(args.runId, args.reply); return { success: true }; @@ -780,11 +811,11 @@ export function setupIpcHandlers() { 'codeMode:getConfig': async () => { const repo = container.resolve('codeModeConfigRepo'); const config = await repo.getConfig(); - return { enabled: config.enabled }; + return { enabled: config.enabled, approvalPolicy: config.approvalPolicy }; }, 'codeMode:setConfig': async (_event, args) => { const repo = container.resolve('codeModeConfigRepo'); - await repo.setConfig({ enabled: args.enabled }); + await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy }); invalidateCopilotInstructionsCache(); return { success: true }; }, @@ -1183,6 +1214,24 @@ export function setupIpcHandlers() { 'voice:synthesize': async (_event, args) => { return voice.synthesizeSpeech(args.text); }, + 'voice:ensureMicAccess': async () => { + if (process.platform !== 'darwin') return { granted: true }; + const status = systemPreferences.getMediaAccessStatus('microphone'); + console.log('[voice] Microphone permission status:', status); + if (status === 'granted') return { granted: true }; + // 'not-determined' shows the native TCC prompt and resolves once the + // user responds; 'denied'/'restricted' resolve false without prompting. + // Awaiting this here means the triggering mic click proceeds to + // getUserMedia only after permission is settled — fixing the first + // click silently failing while the prompt was still up. + try { + const granted = await systemPreferences.askForMediaAccess('microphone'); + console.log('[voice] Microphone permission after prompt:', granted); + return { granted }; + } catch { + return { granted: false }; + } + }, // Live-note handlers 'live-note:run': async (_event, args) => { const result = await runLiveNoteAgent(args.filePath, 'manual', args.context); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 81d43553..40e49e35 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -40,7 +40,8 @@ import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; -import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; +import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; +import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js"; import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; import { setupBrowserEventForwarding } from "./browser/ipc.js"; import { ElectronBrowserControlService } from "./browser/control-service.js"; @@ -220,6 +221,7 @@ function createWindow() { backgroundColor: "#252525", // Prevent white flash (matches dark mode) titleBarStyle: "hiddenInset", trafficLightPosition: { x: 12, y: 12 }, + icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined, webPreferences: { // IMPORTANT: keep Node out of renderer nodeIntegration: false, @@ -251,14 +253,34 @@ function createWindow() { return { action: "deny" }; }); - // Handle navigation to external URLs (e.g., clicking a link without target="_blank") - win.webContents.on("will-navigate", (event, url) => { + // Handle navigation to external URLs (e.g., clicking a link without target="_blank"). + // Returns true when the URL was external and routed to the system browser. + const routeExternalNavigation = (url: string): boolean => { const isInternal = url.startsWith("app://") || url.startsWith("http://localhost:5173"); - if (!isInternal) { - event.preventDefault(); - shell.openExternal(url); - } + if (isInternal) return false; + shell.openExternal(url); + return true; + }; + + win.webContents.on("will-navigate", (event, url) => { + if (routeExternalNavigation(url)) event.preventDefault(); + }); + + // Subframe navigations (e.g. links clicked inside the sandboxed iframe that + // renders a background-task / workspace `index.html`) fire `will-frame-navigate`, + // not `will-navigate`. Route their external links to the system browser too, + // so HTML reports behave like the markdown viewer. Main-frame navigations are + // already handled by `will-navigate` above — skip them here to avoid double-open. + // + // Scope this to our own HTML viewer frames (identified by their app://workspace + // document origin). Third-party note embeds (YouTube, Figma, Twitter via the + // embed/iframe blocks) load from their own origins — leave their internal + // navigation untouched so the embeds keep working. + win.webContents.on("will-frame-navigate", (event) => { + if (event.isMainFrame) return; + if (!event.frame?.url.startsWith("app://workspace/")) return; + if (routeExternalNavigation(event.url)) event.preventDefault(); }); // Attach the embedded browser pane manager to this window. @@ -416,6 +438,12 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); + // Tear down any live ACP coding-agent adapter processes so they don't outlive the app. + try { + container.resolve('codeModeManager').disposeAll(); + } catch { + // nothing live to dispose + } shutdownLocalSites().catch((error) => { console.error('[LocalSites] Failed to shut down cleanly:', error); }); diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 86c6535d..02cfd7bd 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -800,6 +800,108 @@ 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, #1e1e1e); + border: 1px solid var(--gm-border); + border-radius: 10px; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.18), + 0 12px 32px rgba(0, 0, 0, 0.36); + 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 { diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 56445821..b850b57f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -29,6 +29,7 @@ import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; import { EmailView } from '@/components/email-view'; import { WorkspaceView } from '@/components/workspace-view'; +import { CodingRunBlock } from '@/components/coding-run'; import { KnowledgeView } from '@/components/knowledge-view'; import { ChatHistoryView } from '@/components/chat-history-view'; import { HomeView } from '@/components/home-view'; @@ -117,6 +118,7 @@ import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' import * as analytics from '@/lib/analytics' +import { useTheme } from '@/contexts/theme-context' type DirEntry = z.infer type RunEventType = z.infer @@ -165,6 +167,7 @@ function AutoScrollPre({ className, children }: { className?: string; children: } const DEFAULT_SIDEBAR_WIDTH = 256 +const DEFAULT_CHAT_PANE_WIDTH = 460 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ { hue: 210, sat: 72, light: 52 }, @@ -736,6 +739,9 @@ function ContentHeader({ } function App() { + const { chatPanePlacement, chatPaneSize } = useTheme() + const isChatPaneInMiddle = chatPanePlacement === 'middle' + type ShortcutPane = 'left' | 'right' type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } @@ -765,7 +771,7 @@ function App() { // Lives in ViewState so folder drill-down participates in back/forward history. const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState(null) const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) - // Default landing view: Home in the middle with the chat docked on the right. + // Default landing view: Home with the chat docked according to appearance settings. const [isHomeOpen, setIsHomeOpen] = useState(true) const [emailInitialThreadId, setEmailInitialThreadId] = useState(null) const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) @@ -2193,19 +2199,6 @@ function App() { status: 'running', timestamp: Date.now(), }]) - // Detect acpx-driven coding-agent runs so the composer can retroactively - // flip code mode on with the right agent (when the user reached the skill - // via plain prompt rather than the explicit toggle). - if (llmEvent.toolName === 'executeCommand') { - const input = llmEvent.input as { command?: unknown } | undefined - const cmd = typeof input?.command === 'string' ? input.command : '' - const match = cmd.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b/) - if (match) { - window.dispatchEvent(new CustomEvent('code-mode-detected', { - detail: { runId: event.runId, agent: match[1] as 'claude' | 'codex' }, - })) - } - } } else if (llmEvent.type === 'finish-step') { const nextUsage = normalizeUsage(llmEvent.usage) if (nextUsage) { @@ -2303,6 +2296,8 @@ function App() { ...item, result: event.result as ToolUIPart['output'], status: 'completed' as const, + // a code_agent_run finished — drop any lingering permission card + pendingCodePermission: null, } } return item @@ -2383,6 +2378,33 @@ function App() { break } + case 'code-run-event': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + const existing = item.codeRunEvents ?? [] + if (existing.length === 0) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + } + return { ...item, codeRunEvents: [...existing, event.event] } + } + return item + })) + break + } + + case 'code-run-permission-request': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + return { ...item, pendingCodePermission: { requestId: event.requestId, ask: event.ask } } + } + return item + })) + break + } + case 'tool-permission-auto-decision': { if (!isActiveRun) return setAutoPermissionDecisions(prev => { @@ -2725,6 +2747,26 @@ function App() { } }, [runId]) + // Answer a mid-run permission request from a code_agent_run coding turn. The + // pending ask lives on the tool call itself, so we optimistically clear it and + // tell main which decision the user picked (keyed by the request id). + const handleCodePermissionResponse = useCallback(async ( + toolCallId: string, + requestId: string, + decision: 'allow_once' | 'allow_always' | 'reject', + ) => { + setConversation(prev => prev.map(item => + isToolCall(item) && item.id === toolCallId + ? { ...item, pendingCodePermission: null } + : item + )) + try { + await window.ipc.invoke('codeRun:resolvePermission', { requestId, decision }) + } catch (error) { + console.error('Failed to resolve code permission:', error) + } + }, []) + const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => { if (!runId) return try { @@ -5142,6 +5184,21 @@ function App() { } if (isToolCall(item)) { + if (item.name === 'code_agent_run') { + return ( + setToolOpenForTab(tabId, item.id, open)} + onPermissionDecision={(decision) => { + if (item.pendingCodePermission) { + handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision) + } + }} + /> + ) + } const appActionData = getAppActionCardData(item) if (appActionData) { return @@ -5246,6 +5303,17 @@ function App() { const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode + const nonChatPaneStyle = React.useMemo(() => { + const style: React.CSSProperties = { maxWidth: insetMaxWidth } + if (!isRightPaneContext || !isChatSidebarOpen || isRightPaneMaximized) return style + if (chatPaneSize === 'chat-equal') { + return { ...style, width: 0, flex: '1 1 0' } + } + if (chatPaneSize === 'chat-bigger') { + return { ...style, width: DEFAULT_CHAT_PANE_WIDTH, flex: '0 0 auto' } + } + return style + }, [chatPaneSize, insetMaxWidth, isChatSidebarOpen, isRightPaneContext, isRightPaneMaximized]) // Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's // binding immediately (no flex jump), then animate to 0. Expanding goes back to 100% // — its non-binding range lands at the end of the range, where it isn't visible. @@ -5323,10 +5391,11 @@ function App() { setActiveShortcutPane('left')} onFocusCapture={() => setActiveShortcutPane('left')} @@ -5438,7 +5507,11 @@ function App() { : (viewOpen && !isChatSidebarOpen) ? { onClick: openChatSidePane, icon: , label: 'Open chat' } : (viewOpen && isChatSidebarOpen && !isRightPaneMaximized) - ? { onClick: () => setIsChatSidebarOpen(false), icon: , label: 'Expand pane' } + ? { + onClick: () => setIsChatSidebarOpen(false), + icon: isChatPaneInMiddle ? : , + label: 'Expand pane' + } : null return ( @@ -5865,24 +5938,6 @@ function App() { onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - onSwitchAgent={async (newAgent) => { - const runIdForSwitch = tab.runId - await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') - window.dispatchEvent(new CustomEvent('code-mode-detected', { - detail: { runId: runIdForSwitch, agent: newAgent }, - })) - if (runIdForSwitch) { - try { - await window.ipc.invoke('runs:createMessage', { - runId: runIdForSwitch, - message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`, - codeMode: newAgent, - }) - } catch (err) { - console.error('Failed to send swap-agent follow-up', err) - } - } - }} isProcessing={isActive && isProcessing} response={response} /> @@ -5989,10 +6044,13 @@ function App() { )} - {/* Chat sidebar - shown when viewing files/graph */} + {/* Chat pane - shown when viewing files/graph */} {isRightPaneContext && ( & { onApproveSession?: () => void; onApproveAlways?: () => void; onDeny?: () => void; - onSwitchAgent?: (newAgent: 'claude' | 'codex') => void; isProcessing?: boolean; response?: 'approve' | 'deny' | null; permission?: z.infer; @@ -42,7 +40,6 @@ export const PermissionRequest = ({ onApproveSession, onApproveAlways, onDeny, - onSwitchAgent, isProcessing = false, response = null, permission, @@ -56,17 +53,6 @@ export const PermissionRequest = ({ : null; const filePermission = permission?.kind === "file" ? permission : null; - // Detect acpx coding-agent invocations so we can show the agent identity and - // offer a one-click swap-and-retry. - const acpxAgent: 'claude' | 'codex' | null = (() => { - if (!command) return null; - const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/); - return match ? (match[1] as 'claude' | 'codex') : null; - })(); - const otherAgent: 'claude' | 'codex' | null = acpxAgent === 'claude' ? 'codex' : acpxAgent === 'codex' ? 'claude' : null; - const agentDisplay = acpxAgent === 'claude' ? 'Claude Code' : acpxAgent === 'codex' ? 'Codex' : null; - const otherDisplay = otherAgent === 'claude' ? 'Claude Code' : otherAgent === 'codex' ? 'Codex' : null; - const isResponded = response !== null; const isApproved = response === 'approve'; @@ -104,15 +90,6 @@ export const PermissionRequest = ({

{isResponded ? "Requested:" : "The agent wants to execute:"} {toolCall.toolName} - {agentDisplay && ( - - - {agentDisplay} - - )}

{isResponded && ( @@ -220,18 +197,6 @@ export const PermissionRequest = ({ Deny - {otherAgent && otherDisplay && onSwitchAgent && ( - - )} )} diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index b4574b58..0641bc13 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -19,6 +19,7 @@ import type { ConversationItem } from '@/lib/chat-conversation' import { runLogToConversation } from '@/lib/run-to-conversation' import { CompactConversation } from '@/components/compact-conversation' import { RichMarkdownViewer } from '@/components/rich-markdown-viewer' +import { HtmlFileViewer } from '@/components/html-file-viewer' // --------------------------------------------------------------------------- // Trigger helpers (inlined; extract to shared as a follow-up) @@ -502,15 +503,22 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re } // --------------------------------------------------------------------------- -// Output pane — index.md (main pane content) +// Output pane — index.html (preferred) or index.md (main pane content) // -// Renders the task's `index.md` like a note: max-width 720px centered, same -// typography (~16px, 1.5 line-height, generous padding) as the note editor's -// ProseMirror rule in `editor.css`. No chrome above the body — just the -// markdown, with a small floating Source ⇄ Rendered toggle in the top-right. +// A task's agent-owned artifact is either: +// - `index.html` — a self-contained, styled web page. Rendered full-bleed in +// a sandboxed iframe (via `HtmlFileViewer` / the `app://workspace` +// protocol) so CSS, layout, and scripts render faithfully. Preferred when +// present and non-empty. +// - `index.md` — a note. Rendered like the note editor: max-width 720px +// centered, same typography as `editor.css`, via `RichMarkdownViewer`. +// +// In both cases a small floating Source ⇄ Rendered toggle in the top-right +// swaps the rendered view for the raw file source. // --------------------------------------------------------------------------- function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: string; refreshKey: number }) { + const [mode, setMode] = useState<'md' | 'html'>('md') const [body, setBody] = useState('') const [loading, setLoading] = useState(true) const [viewSource, setViewSource] = useState(false) @@ -519,21 +527,33 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st let cancelled = false setLoading(true) void (async () => { + // Prefer index.html when it exists and has content; otherwise fall + // back to index.md (the default seeded artifact). try { - const result = await window.ipc.invoke('workspace:readFile', { + const html = await window.ipc.invoke('workspace:readFile', { + path: `bg-tasks/${slug}/index.html`, + }) + if (html.data.trim()) { + if (!cancelled) { setMode('html'); setBody(html.data) } + return + } + } catch { + // No index.html — fall through to markdown. + } + try { + const md = await window.ipc.invoke('workspace:readFile', { path: `bg-tasks/${slug}/index.md`, }) - if (!cancelled) setBody(result.data) + if (!cancelled) { setMode('md'); setBody(md.data) } } catch { - if (!cancelled) setBody('') - } finally { - if (!cancelled) setLoading(false) + if (!cancelled) { setMode('md'); setBody('') } } - })() + })().finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [slug, refreshKey]) - const isEmpty = !body.trim() || body.trim() === `# ${taskName}` + const isEmpty = mode === 'md' && (!body.trim() || body.trim() === `# ${taskName}`) + const showHtml = mode === 'html' && !viewSource return (
@@ -542,29 +562,35 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st type="button" onClick={() => setViewSource(v => !v)} className="absolute right-4 top-3 z-10 rounded-md bg-background/70 px-2 py-0.5 text-[11px] text-muted-foreground backdrop-blur hover:bg-accent hover:text-foreground" - aria-label={viewSource ? 'Show rendered output' : 'Show source markdown'} + aria-label={viewSource ? 'Show rendered output' : 'Show source'} > {viewSource ? 'Rendered' : 'Source'} )} -
-
- {loading ? ( -
- Loading… -
- ) : isEmpty ? ( -

- No output yet. Click Run now in the sidebar, or wait for a trigger to fire. -

- ) : viewSource ? ( -
{body}
- ) : ( - - )} + {showHtml ? ( + // Full-bleed: the iframe fills the pane and scrolls internally. + // Remount on refreshKey so a re-run's updated index.html reloads. + + ) : ( +
+
+ {loading ? ( +
+ Loading… +
+ ) : isEmpty ? ( +

+ No output yet. Click Run now in the sidebar, or wait for a trigger to fire. +

+ ) : viewSource ? ( +
{body}
+ ) : ( + + )} +
-
+ )}
) } @@ -1237,6 +1263,8 @@ function TaskDetail({ const [confirmingDelete, setConfirmingDelete] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(true) const [outputRefreshKey, setOutputRefreshKey] = useState(0) + // Whether we've already chosen the initial sidebar state for this task. + const sidebarInitialized = useRef(false) const agentStatus = useBackgroundTaskAgentStatus() const liveStatus = agentStatus.get(slug) @@ -1252,6 +1280,23 @@ function TaskDetail({ if (result.success && result.task) { setTask(result.task) setDraft(result.task) + // On first open, collapse the details sidebar when the agent + // already has output — let the user read it without chrome. + // Resolved before `loading` clears so the sidebar never flashes. + if (!sidebarInitialized.current) { + sidebarInitialized.current = true + try { + const out = await window.ipc.invoke('workspace:readFile', { + path: `bg-tasks/${slug}/index.md`, + }) + const body = (out.data ?? '').trim() + if (body && body !== `# ${result.task.name}`) { + setSidebarOpen(false) + } + } catch { + // No output file yet — keep the sidebar open. + } + } } } finally { setLoading(false) 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 8c62054c..0254cdfd 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 @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ArrowUp, @@ -10,12 +10,16 @@ import { FileSpreadsheet, FileText, FileVideo, + FolderCheck, + FolderClock, FolderCog, + FolderOpen, Globe, Headphones, ImagePlus, LoaderIcon, Mic, + MoreHorizontal, Plus, ShieldCheck, Square, @@ -26,10 +30,14 @@ import { import { Button } from '@/components/ui/button' import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { @@ -61,6 +69,12 @@ export type StagedAttachment = { } const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB +const MAX_VISIBLE_RECENT_WORK_DIRS = 3 +const MAX_STORED_RECENT_WORK_DIRS = 8 +// Stored in the workspace (~/.rowboat/config) so it travels with the workspace and +// stays consistent with the other config/*.json files (e.g. coding-agents.json). +const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json' +const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed' const providerDisplayNames: Record = { @@ -81,6 +95,11 @@ interface ConfiguredModel { model: string } +type RecentWorkDir = { + path: string + lastUsedAt: number +} + export interface SelectedModel { provider: string model: string @@ -111,6 +130,84 @@ function getAttachmentIcon(kind: AttachmentIconKind) { } } +function normalizeRecentWorkDir(value: unknown): RecentWorkDir | null { + if (typeof value === 'string') { + const path = value.trim() + return path ? { path, lastUsedAt: 0 } : null + } + if (!value || typeof value !== 'object') return null + const entry = value as Record + const path = typeof entry.path === 'string' ? entry.path.trim() : '' + const lastUsedAt = typeof entry.lastUsedAt === 'number' && Number.isFinite(entry.lastUsedAt) + ? entry.lastUsedAt + : 0 + return path ? { path, lastUsedAt } : null +} + +async function readRecentWorkDirs(): Promise { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: RECENT_WORK_DIRS_CONFIG_PATH }) + const parsed = JSON.parse(result.data) + if (!Array.isArray(parsed)) return [] + const seen = new Set() + const dirs: RecentWorkDir[] = [] + for (const value of parsed) { + const entry = normalizeRecentWorkDir(value) + if (!entry || seen.has(entry.path)) continue + seen.add(entry.path) + dirs.push(entry) + if (dirs.length >= MAX_STORED_RECENT_WORK_DIRS) break + } + return dirs + } catch { + // File missing or invalid — no recents yet. + return [] + } +} + +async function writeRecentWorkDirs(dirs: RecentWorkDir[]) { + try { + await window.ipc.invoke('workspace:writeFile', { + path: RECENT_WORK_DIRS_CONFIG_PATH, + data: JSON.stringify(dirs.slice(0, MAX_STORED_RECENT_WORK_DIRS), null, 2), + }) + } catch (err) { + console.error('Failed to persist recent work directories', err) + } + // Notify other mounted chat inputs in this window to re-read. + window.dispatchEvent(new CustomEvent(RECENT_WORK_DIRS_CHANGED_EVENT)) +} + +function formatRecentWorkDirTime(lastUsedAt: number) { + if (!lastUsedAt) return '' + const now = Date.now() + const diffMs = Math.max(0, now - lastUsedAt) + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + if (diffMs < minute) return 'now' + if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))}m ago` + if (diffMs < day) return `${Math.floor(diffMs / hour)}h ago` + + const used = new Date(lastUsedAt) + const yesterday = new Date(now - day) + if ( + used.getFullYear() === yesterday.getFullYear() && + used.getMonth() === yesterday.getMonth() && + used.getDate() === yesterday.getDate() + ) { + return 'Yesterday' + } + if (diffMs < 7 * day) { + return used.toLocaleDateString(undefined, { weekday: 'short' }) + } + return used.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +} + +function compactWorkDirPath(path: string) { + return path.replace(/^\/Users\/[^/]+/, '~') +} + interface ChatInputInnerProps { onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void onStop?: () => void @@ -186,6 +283,52 @@ function ChatInputInner({ const [codeModeEnabled, setCodeModeEnabled] = useState(false) const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) const [permissionMode, setPermissionMode] = useState('auto') + const [recentWorkDirs, setRecentWorkDirs] = useState([]) + + // Responsive toolbar: measure real overflow and progressively collapse items + // right→left until everything fits. Stages: + // 1 code→icon · 2 perm→icon · 3 search label hidden · 4 workDir→icon + // 5 code→menu · 6 perm→menu · 7 search→menu · 8 workDir→menu + // Once items move into the "⋯" overflow menu (≥5) no icon is ever hidden. + // overflow-hidden on the left group is the hard guarantee against any overlap. + const toolbarRef = useRef(null) + const leftGroupRef = useRef(null) + const lastWidthRef = useRef(0) + const [collapseLevel, setCollapseLevel] = useState(0) + + // Re-evaluate from scratch (level 0) whenever the available width changes… + useEffect(() => { + const outer = toolbarRef.current + if (!outer) return + const ro = new ResizeObserver(() => { + const w = outer.clientWidth + if (w !== lastWidthRef.current) { + lastWidthRef.current = w + setCollapseLevel(0) + } + }) + ro.observe(outer) + return () => ro.disconnect() + }, []) + + // …or when the *set* of items changes (an item appears/disappears, or the model + // name width changes). Deliberately excludes the in-place toggles (searchEnabled, + // permissionMode, codeModeEnabled, codingAgent): those fire from the overflow menu + // for items already inside it, so resetting here would unmount the open menu. The + // no-dep effect below still re-collapses if any toggle happens to widen the row. + useLayoutEffect(() => { + setCollapseLevel(0) + }, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, activeModelKey]) + + // After each render, if the left group still overflows, collapse one more step. + // Runs before paint, so the intermediate (overflowing) state is never visible. + useLayoutEffect(() => { + const el = leftGroupRef.current + if (!el) return + if (el.scrollWidth > el.clientWidth + 1 && collapseLevel < 8) { + setCollapseLevel((l) => Math.min(8, l + 1)) + } + }) // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { @@ -205,6 +348,15 @@ function ChatInputInner({ return () => { cancelled = true } }, [runId]) + useEffect(() => { + const syncRecentWorkDirs = () => { void readRecentWorkDirs().then(setRecentWorkDirs) } + syncRecentWorkDirs() + window.addEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs) + return () => { + window.removeEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs) + } + }, []) + // Check Rowboat sign-in state useEffect(() => { window.ipc.invoke('oauth:getState', null).then((result) => { @@ -289,20 +441,6 @@ function ChatInputInner({ } }, [codeModeFeatureEnabled, codeModeEnabled]) - // Listen for coding-agent runs that were triggered without the explicit code-mode - // toggle. App.tsx dispatches this when it sees an acpx executeCommand fire. We - // flip the pill on with the detected agent so the UI reflects what's happening. - useEffect(() => { - const handler = (ev: Event) => { - const detail = (ev as CustomEvent<{ runId?: string; agent?: 'claude' | 'codex' }>).detail - if (!detail || !detail.agent) return - if (runId && detail.runId && detail.runId !== runId) return - setCodeModeEnabled(true) - setCodingAgent(detail.agent) - } - window.addEventListener('code-mode-detected', handler) - return () => window.removeEventListener('code-mode-detected', handler) - }, [runId]) // Cross-platform basename — handles both / and \ separators. const basename = useCallback((p: string): string => { @@ -311,6 +449,17 @@ function ChatInputInner({ return idx >= 0 ? trimmed.slice(idx + 1) : trimmed }, []) + const rememberWorkDir = useCallback(async (dir: string) => { + const trimmed = dir.trim() + if (!trimmed) return + const next = [ + { path: trimmed, lastUsedAt: Date.now() }, + ...(await readRecentWorkDirs()).filter((item) => item.path !== trimmed), + ].slice(0, MAX_STORED_RECENT_WORK_DIRS) + setRecentWorkDirs(next) + await writeRecentWorkDirs(next) + }, []) + // Load coding-agent preference for a given workdir. // Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' } const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => { @@ -327,7 +476,7 @@ function ChatInputInner({ }, []) const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => { - let existing: Record = {} + const existing: Record = {} try { const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' }) const parsed = JSON.parse(result.data) as Record @@ -353,6 +502,10 @@ function ChatInputInner({ return () => { cancelled = true } }, [workDir, loadCodingAgentFor]) + useEffect(() => { + if (isActive && workDir) void rememberWorkDir(workDir) + }, [isActive, workDir, rememberWorkDir]) + const handleSetWorkDir = useCallback(async () => { try { let defaultPath: string | undefined = workDir ?? undefined @@ -373,13 +526,21 @@ function ChatInputInner({ }) if (!chosen) return onWorkDirChange?.(chosen) + await rememberWorkDir(chosen) setCodingAgent(await loadCodingAgentFor(chosen)) toast.success(`Work directory set: ${chosen}`) } catch (err) { console.error('Failed to set work directory', err) toast.error('Failed to set work directory') } - }, [workDir, onWorkDirChange, loadCodingAgentFor]) + }, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor]) + + const handleSelectRecentWorkDir = useCallback(async (dir: string) => { + onWorkDirChange?.(dir) + await rememberWorkDir(dir) + setCodingAgent(await loadCodingAgentFor(dir)) + toast.success(`Work directory set: ${dir}`) + }, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor]) const handleClearWorkDir = useCallback(() => { onWorkDirChange?.(null) @@ -533,6 +694,12 @@ function ChatInputInner({ } }, [addFiles, isActive]) + const visibleRecentWorkDirs = recentWorkDirs + .filter((entry) => entry.path !== workDir) + .slice(0, MAX_VISIBLE_RECENT_WORK_DIRS) + const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set' + const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : '' + return (
{attachments.length > 0 && ( @@ -637,7 +804,8 @@ function ChatInputInner({ className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0" />
-
+
+
@@ -651,39 +819,123 @@ function ChatInputInner({ - Add files or set work directory + + {workDir ? 'Add files or change work directory' : 'Add files or set work directory'} + - - fileInputRef.current?.click()}> - - Add files or photos - - { void handleSetWorkDir() }}> - - {workDir ? 'Change work directory' : 'Set work directory'} - + +
+ fileInputRef.current?.click()} className="h-9 rounded-[9px] px-2.5"> + + Add files or photos + + + {/* Working directory lives behind a submenu so the main menu stays to two + items. One hover/click away for power users; out of the way otherwise. */} + + + + + Set working directory + + {currentWorkDirLabel} + + + + + {/* Current selection — shown for context only when one is set. */} + {workDir && ( +
+ + + {currentWorkDirLabel} + + {currentWorkDirPath} + + +
+ )} + + {/* Primary action: choose when unset, change when set. Always on top. */} + { void handleSetWorkDir() }} + className="h-9 rounded-[9px] px-2.5" + > + + {workDir ? 'Change folder…' : 'Choose a folder…'} + + + {visibleRecentWorkDirs.length > 0 && ( + <> +
+ Recent +
+ {visibleRecentWorkDirs.map((entry) => { + const name = basename(entry.path) || entry.path + const when = formatRecentWorkDirTime(entry.lastUsedAt) + return ( + { void handleSelectRecentWorkDir(entry.path) }} + className="h-8 rounded-[9px] px-2.5" + > + + {name} + {when && {when}} + + ) + })} + + )} + + {/* Clear — only meaningful once a directory is set. Kept at the bottom. */} + {workDir && ( + <> +
+ + + Clear folder + + + )} + + +
- {workDir && ( + {workDir && collapseLevel < 8 && ( -
+ {/* Level 4: collapse to a square icon */} +
= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2" + )}> - + {collapseLevel < 4 && ( + + )}
@@ -691,7 +943,7 @@ function ChatInputInner({ )} - {searchAvailable && ( + {searchAvailable && collapseLevel < 7 && ( )} + {collapseLevel < 6 && ( @@ -745,37 +996,54 @@ function ChatInputInner({ : 'Manual approval prompts — click for auto-permission'} - {codeModeFeatureEnabled && (codeModeEnabled ? ( -
+ )} + {codeModeFeatureEnabled && collapseLevel < 5 && (codeModeEnabled ? ( + collapseLevel >= 1 ? ( + /* Level 1: collapse the pill to a single icon */ - Code mode on — click to disable + Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable - · - - - - - - Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap - - -
+ ) : ( +
+ + + + + Code mode on — click to disable + + · + + + + + + Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap + + +
+ ) ) : ( @@ -791,25 +1059,89 @@ function ChatInputInner({ Use a coding agent (Claude Code or Codex) ))} +
+ {collapseLevel >= 5 && ( + + + + + + + + More options + + + {workDir && collapseLevel >= 8 && ( + { void handleSetWorkDir() }}> + + {basename(workDir) || workDir} + + )} + {searchAvailable && collapseLevel >= 7 && ( + e.preventDefault()} + onCheckedChange={(c) => setSearchEnabled(Boolean(c))} + > + Web search + + )} + {collapseLevel >= 6 && ( + e.preventDefault()} + onCheckedChange={(c) => setPermissionMode(c ? 'auto' : 'manual')} + > + Auto-approve actions + + )} + {codeModeFeatureEnabled && collapseLevel >= 5 && ( + <> + e.preventDefault()} + onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))} + > + Code mode + + {codeModeEnabled && ( + { e.preventDefault(); handleToggleCodingAgent() }}> + + Coding agent + {codingAgent === 'claude' ? 'Claude' : 'Codex'} + + )} + + )} + + + )}
{lockedModel ? ( - {getSelectedModelDisplayName(lockedModel.model)} + {getSelectedModelDisplayName(lockedModel.model)} ) : configuredModels.length > 0 ? ( diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index f8923e4f..6300f4cc 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -41,6 +41,7 @@ import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { useSidebar } from '@/components/ui/sidebar' import { wikiLabel } from '@/lib/wiki-links' +import type { ChatPaneSize } from '@/contexts/theme-context' import { type ChatViewportAnchorState, type ChatTabViewState, @@ -125,6 +126,9 @@ interface ChatSidebarProps { defaultWidth?: number isOpen?: boolean isMaximized?: boolean + placement?: 'middle' | 'right' + paneSize?: ChatPaneSize + className?: string chatTabs: ChatTab[] activeChatTabId: string getChatTabTitle: (tab: ChatTab) => string @@ -183,6 +187,9 @@ export function ChatSidebar({ defaultWidth = DEFAULT_WIDTH, isOpen = true, isMaximized = false, + placement = 'right', + paneSize = 'chat-smaller', + className, chatTabs, activeChatTabId, getChatTabTitle, @@ -246,6 +253,8 @@ export function ChatSidebar({ const startWidthRef = useRef(0) const prevIsMaximizedRef = useRef(isMaximized) const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized + const isMiddlePlacement = placement === 'middle' + const isResizable = paneSize === 'chat-smaller' const getMaxAllowedWidth = useCallback(() => { if (typeof window === 'undefined') return MAX_WIDTH @@ -306,7 +315,9 @@ export function ChatSidebar({ setIsResizing(true) const handleMouseMove = (event: MouseEvent) => { - const delta = startXRef.current - event.clientX + const delta = isMiddlePlacement + ? event.clientX - startXRef.current + : startXRef.current - event.clientX const maxAllowedWidth = getMaxAllowedWidth() setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth)) } @@ -319,7 +330,7 @@ export function ChatSidebar({ document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) - }, [width, getMaxAllowedWidth]) + }, [width, getMaxAllowedWidth, isMiddlePlacement]) const activeTabState = useMemo(() => ({ runId: runId ?? null, @@ -501,8 +512,11 @@ export function ChatSidebar({ // not add extra width to the right and overflow the app viewport. return { width: 0, flex: '1 1 auto' } } + if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') { + return { width: 0, flex: '1 1 0' } + } return { width, flex: '0 0 auto' } - }, [isOpen, isMaximized, width]) + }, [isOpen, isMaximized, paneSize, width]) return (
- {!isMaximized && ( + {!isMaximized && isResizable && (
- {isMaximized ? : } + {isMaximized + ? (isMiddlePlacement ? : ) + : (isMiddlePlacement ? : )} {isMaximized ? 'Dock to side pane' : 'Expand chat'} diff --git a/apps/x/apps/renderer/src/components/coding-run.tsx b/apps/x/apps/renderer/src/components/coding-run.tsx new file mode 100644 index 00000000..4d5dd33b --- /dev/null +++ b/apps/x/apps/renderer/src/components/coding-run.tsx @@ -0,0 +1,253 @@ +import { useMemo, useState } from 'react' +import { + CheckCircle2, + Circle, + CircleDot, + Eye, + FileText, + Loader, + Pencil, + Search, + ShieldQuestion, + Terminal, + Trash2, + Wrench, +} from 'lucide-react' +import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js' +import { cn } from '@/lib/utils' +import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool' +import { toToolState, type ToolCall } from '@/lib/chat-conversation' + +// ── Timeline reduction ────────────────────────────────────────────── +// The raw ACP stream is a flat list of events; collapse it into ordered rows, +// folding tool_call + tool_call_update (by id) and the latest plan in place. + +type TextRow = { kind: 'text'; id: string; text: string } +type ToolRow = { kind: 'tool'; id: string; title?: string; toolKind?: string; status?: string; diffs: string[] } +type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?: string }[] } +type PermRow = { kind: 'perm'; id: string; title: string; decision: string } +type Row = TextRow | ToolRow | PlanRow | PermRow + +function reduceEvents(events: CodeRunEvent[]): Row[] { + const rows: Row[] = [] + const toolIdx = new Map() + let planIdx = -1 + + events.forEach((e, i) => { + switch (e.type) { + case 'message': { + if (e.role !== 'agent' || !e.text) return + const last = rows[rows.length - 1] + if (last && last.kind === 'text') last.text += e.text + else rows.push({ kind: 'text', id: `t${i}`, text: e.text }) + break + } + case 'tool_call': { + const id = e.id ?? `tc${i}` + const at = toolIdx.get(id) + if (at != null) { + const r = rows[at] as ToolRow + r.title = e.title ?? r.title + r.toolKind = e.kind ?? r.toolKind + r.status = e.status ?? r.status + } else { + toolIdx.set(id, rows.length) + rows.push({ kind: 'tool', id, title: e.title, toolKind: e.kind, status: e.status, diffs: [] }) + } + break + } + case 'tool_call_update': { + const id = e.id ?? `tu${i}` + let at = toolIdx.get(id) + if (at == null) { + at = rows.length + toolIdx.set(id, at) + rows.push({ kind: 'tool', id, diffs: [] }) + } + const r = rows[at] as ToolRow + if (e.status) r.status = e.status + for (const d of e.diffs) if (!r.diffs.includes(d)) r.diffs.push(d) + break + } + case 'plan': { + if (planIdx >= 0) (rows[planIdx] as PlanRow).entries = e.entries + else { + planIdx = rows.length + rows.push({ kind: 'plan', id: 'plan', entries: e.entries }) + } + break + } + case 'permission': + rows.push({ kind: 'perm', id: `p${i}`, title: e.ask.title, decision: e.decision }) + break + default: + break + } + }) + return rows +} + +function toolKindIcon(kind?: string) { + switch (kind) { + case 'read': return + case 'edit': return + case 'delete': return + case 'search': return + case 'execute': return + case 'fetch': return + default: return + } +} + +function planMarker(status?: string) { + if (status === 'completed') return + if (status === 'in_progress') return + return +} + +const basename = (p: string) => p.split(/[\\/]/).pop() || p + +function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) { + const rows = useMemo(() => reduceEvents(events), [events]) + if (rows.length === 0) { + return
Starting the agent…
+ } + return ( +
+ {rows.map((row) => { + if (row.kind === 'text') { + return ( +

+ {row.text} +

+ ) + } + if (row.kind === 'tool') { + const running = row.status !== 'completed' && row.status !== 'failed' + return ( +
+
+ {running + ? + : } + {toolKindIcon(row.toolKind)} + {row.title ?? row.toolKind ?? 'Tool call'} +
+ {row.diffs.length > 0 && ( +
+ {row.diffs.map((d) => ( + + {basename(d)} + + ))} +
+ )} +
+ ) + } + if (row.kind === 'plan') { + return ( +
+ {row.entries.map((entry, idx) => ( +
+ {planMarker(entry.status)} + + {entry.content} + +
+ ))} +
+ ) + } + // resolved permission + const denied = row.decision === 'reject' || row.decision === 'cancelled' + return ( +
+ {denied ? '✕' : '✓'} + {denied ? 'Denied' : 'Allowed'}: {row.title} +
+ ) + })} +
+ ) +} + +// ── In-run permission card ────────────────────────────────────────── + +export function CodeRunPermissionRequest({ + ask, + onDecide, +}: { + ask: PermissionAsk + onDecide: (decision: PermissionDecision) => void +}) { + const [busy, setBusy] = useState(false) + const decide = (d: PermissionDecision) => { + if (busy) return + setBusy(true) + onDecide(d) + } + const btn = 'rounded-full px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50' + return ( +
+
+ + Permission needed +
+

+ The agent wants to: {ask.title} +

+
+ + + +
+
+ ) +} + +// ── Block wrapper (rendered in the chat for a code_agent_run tool call) ── + +const AGENT_LABEL: Record = { claude: 'Claude Code', codex: 'Codex' } + +export function CodingRunBlock({ + item, + open, + onOpenChange, + onPermissionDecision, +}: { + item: ToolCall + open: boolean + onOpenChange: (open: boolean) => void + onPermissionDecision: (decision: PermissionDecision) => void +}) { + // Prefer the agent the backend actually ran (the chip) once the run returns; fall + // back to the requested input agent while it's still in flight. Never trust only the + // model's input — it can pass a stale agent the backend overrode with the chip. + const agent = + (item.result as { agent?: string } | undefined)?.agent ?? + (item.input as { agent?: string } | undefined)?.agent + const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent' + return ( + <> + + + + + + + {item.pendingCodePermission && ( + + )} + + ) +} diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index dea0561e..86f79fa7 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -612,6 +612,43 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () ) } +type ContactSuggestion = { + name: string + email: string +} + +function formatContactToken(c: ContactSuggestion): string { + return c.name ? `${c.name} <${c.email}>` : c.email +} + +// Stable hue per email so the avatar circle keeps a consistent color. +function contactHue(email: string): number { + let h = 0 + for (let i = 0; i < email.length; i++) h = (h * 31 + email.charCodeAt(i)) >>> 0 + return h % 360 +} + +function contactInitial(c: ContactSuggestion): string { + const src = (c.name || c.email).trim() + return (src[0] || '?').toUpperCase() +} + +// Renders a string with the matched substring wrapped in . +function HighlightedText({ text, query }: { text: string; query: string }) { + if (!query) return <>{text} + const lower = text.toLowerCase() + const q = query.toLowerCase() + const idx = lower.indexOf(q) + if (idx < 0) return <>{text} + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + q.length)} + {text.slice(idx + q.length)} + + ) +} + function RecipientField({ label, value, @@ -626,34 +663,123 @@ function RecipientField({ trailing?: React.ReactNode }) { const [draft, setDraft] = useState('') + const [suggestions, setSuggestions] = useState([]) + const [activeIndex, setActiveIndex] = useState(0) + const [isFocused, setIsFocused] = useState(false) + const [queryShown, setQueryShown] = useState('') const inputRef = useRef(null) + const fieldRef = useRef(null) + const listRef = useRef(null) + const queryTokenRef = useRef(0) useEffect(() => { if (autoFocus) inputRef.current?.focus() }, [autoFocus]) + const excludeEmails = useMemo( + () => value.map((token) => extractAddress(token).toLowerCase()).filter(Boolean), + [value], + ) + + // Debounced contact search — only runs when the user has actually typed + // something. An empty draft (including the post-pick reset) closes the menu. + useEffect(() => { + const trimmed = draft.trim() + if (!isFocused || !trimmed) { + queryTokenRef.current++ + setSuggestions([]) + return + } + const token = ++queryTokenRef.current + const timer = window.setTimeout(async () => { + try { + const result = (await window.ipc.invoke('gmail:searchContacts', { + query: draft, + limit: 8, + excludeEmails, + })) as { contacts?: ContactSuggestion[] } | undefined + if (token !== queryTokenRef.current) return + setSuggestions(result?.contacts ?? []) + setQueryShown(trimmed) + setActiveIndex(0) + } catch { + if (token !== queryTokenRef.current) return + setSuggestions([]) + } + }, 60) + return () => window.clearTimeout(timer) + }, [draft, isFocused, excludeEmails]) + + // Keep the active row scrolled into view during keyboard navigation. + useEffect(() => { + const list = listRef.current + if (!list) return + const node = list.children[activeIndex] as HTMLElement | undefined + node?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, suggestions]) + const commit = (raw: string) => { const additions = splitAddresses(raw) if (additions.length === 0) return onChange(dedupeRecipients([...value, ...additions], new Set())) setDraft('') + setSuggestions([]) + } + + const pickSuggestion = (c: ContactSuggestion) => { + commit(formatContactToken(c)) + // Keep focus in the input so the user can keep typing more recipients. + inputRef.current?.focus() } const onKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) { + const hasSuggestions = suggestions.length > 0 + if (event.key === 'ArrowDown' && hasSuggestions) { + event.preventDefault() + setActiveIndex((i) => (i + 1) % suggestions.length) + return + } + if (event.key === 'ArrowUp' && hasSuggestions) { + event.preventDefault() + setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length) + return + } + if (event.key === 'Escape' && hasSuggestions) { + event.preventDefault() + setSuggestions([]) + return + } + if (event.key === 'Enter' || (event.key === 'Tab' && hasSuggestions)) { + // Prefer the highlighted suggestion when one is present. + if (hasSuggestions) { + event.preventDefault() + pickSuggestion(suggestions[activeIndex]) + return + } + if (event.key === 'Enter' && draft.trim()) { + event.preventDefault() + commit(draft) + return + } + } + if (event.key === ',' || event.key === ';') { if (draft.trim()) { event.preventDefault() commit(draft) } - } else if (event.key === 'Backspace' && !draft && value.length > 0) { + return + } + if (event.key === 'Backspace' && !draft && value.length > 0) { onChange(value.slice(0, -1)) } } + const showSuggestions = isFocused && suggestions.length > 0 + return (
{label} -
+
{value.map((token, index) => ( {recipientLabel(token)} @@ -674,7 +800,16 @@ function RecipientField({ value={draft} onChange={(event) => setDraft(event.target.value)} onKeyDown={onKeyDown} - onBlur={() => { if (draft.trim()) commit(draft) }} + onFocus={() => setIsFocused(true)} + onBlur={() => { + // Defer so a mousedown on a suggestion can pick it before the menu closes. + window.setTimeout(() => { + setIsFocused(false) + if (inputRef.current && draft.trim() && document.activeElement !== inputRef.current) { + commit(draft) + } + }, 80) + }} onPaste={(event) => { const text = event.clipboardData.getData('text') if (text && /[,;\n]/.test(text)) { @@ -683,6 +818,45 @@ function RecipientField({ } }} /> + {showSuggestions && ( +
    + {suggestions.map((c, idx) => { + const hue = contactHue(c.email) + return ( +
  • { + // Prevent input blur before click fires. + event.preventDefault() + pickSuggestion(c) + }} + onMouseEnter={() => setActiveIndex(idx)} + > + + + + + + {c.name && ( + + + + )} + +
  • + ) + })} +
+ )}
{trailing &&
{trailing}
}
diff --git a/apps/x/apps/renderer/src/components/html-file-viewer.tsx b/apps/x/apps/renderer/src/components/html-file-viewer.tsx index 8343af28..79cccbc7 100644 --- a/apps/x/apps/renderer/src/components/html-file-viewer.tsx +++ b/apps/x/apps/renderer/src/components/html-file-viewer.tsx @@ -110,7 +110,12 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {